# Africa Alert PWA — Project Conventions This document captures the load-bearing conventions for the Africa Alert PWA. Each rein's `agent.md` links here instead of inlining these rules. Keep this file short and operational. ## Tech stack (lock these in) - **Frontend:** React 18 + Vite + TypeScript, `react-router-dom` v6, `axios`, `zustand`, `recharts`, `lucide-react`, `vite-plugin-pwa`. - **Backend:** Node.js + Express, `better-sqlite3` (NOT `sqlite3`), `jsonwebtoken` + `bcryptjs` (NOT native `bcrypt`), `multer`, `@supabase/supabase-js` (used by SyncEngine). - **DB:** SQLite, WAL mode, `foreign_keys = ON`. - **Cloud:** Supabase REST API (`https://api.next_gen.techarvest.co.zw`). - **Payments:** Paynow (Zimbabwe). - **Deploy:** Docker Compose (multi-stage `Dockerfile`). ## Directory layout (don't deviate) ``` africa-alert-pwa/ ├── client/ │ └── src/ │ ├── App.tsx # router + ProtectedRoute + role-based getRoutes() │ ├── main.tsx │ ├── components/ # Nav, PaynowPayment, shared UI │ ├── pages/ # feature pages │ │ ├── admin/ # admin-only pages │ │ ├── teacher/ # teacher-only pages │ │ ├── student/ # student-only pages │ │ ├── parent/ # parent-only pages │ │ ├── dashboard/ # role landing pages │ │ └── exams/ # exam module (canonical reference) │ └── store/ # Zustand slices (auth, api, exams, …) ├── server/ │ └── src/ │ ├── index.js # Express bootstrap, registers 12 controllers │ ├── controllers/ # one file per resource │ ├── services/ │ │ └── SyncEngine.js # bidirectional SQLite ↔ Supabase │ └── database/ │ └── init.js # schema, idempotent ├── data/ # SQLite file (gitignored) ├── uploads/ # multer uploads (gitignored) ├── docker-compose.yml ├── Dockerfile # multi-stage └── .harness/ # team definition (committed) ├── agent.md # orchestrator ├── reins/ # specialists ├── docs/ # project conventions └── changelogs/ # daily change log ``` ## SQL contract (every table must have these) ```sql id INTEGER PRIMARY KEY AUTOINCREMENT, uid TEXT UNIQUE, -- stable cross-system id created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_synced_at DATETIME, sync_status TEXT DEFAULT 'pending' CHECK(sync_status IN ('synced', 'pending', 'conflict')), is_deleted INTEGER DEFAULT 0 ``` Adding a table is a two-step commit: 1. Add the `CREATE TABLE IF NOT EXISTS` block in `server/src/database/init.js` (backend-expert). 2. Append the table name to `tablesToSync` in `server/src/services/SyncEngine.js` (sync-expert) — in **dependency order** (parents before children). ## Controller template (canonical) ```js const express = require('express'); const path = require('path'); const Database = require('better-sqlite3'); const db = new Database(process.env.DB_PATH || path.join(__dirname, '../../../data/school.db')); db.pragma('foreign_keys = ON'); const router = express.Router(); // inline auth middleware (or use the one from server/src/index.js) const auth = (req, res, next) => { /* … */ }; // CRUD handlers — all writes set sync_status = 'pending' router.get('/', auth, (req, res) => { /* … */ }); router.post('/', auth, (req, res) => { /* … */ }); // … module.exports = router; ``` Register in `server/src/index.js`: ```js app.use('/api/', require('./controllers/.controller')); ``` Alphabetical order, please. ## Frontend store template (canonical) ```ts // client/src/store/.ts import { create } from 'zustand'; import { api } from './api'; interface XStore { items: any[]; currentItem: any | null; fetchItems: (filters?: any) => Promise; createItem: (data: any) => Promise; updateItem: (id: number, data: any) => Promise; deleteItem: (id: number) => Promise; } export const useXStore = create((set, get) => ({ items: [], currentItem: null, fetchItems: async (filters) => { const params = new URLSearchParams(filters || {}); const { data } = await api.get(`/?${params}`); set({ items: data }); }, createItem: async (data) => { /* … */ }, updateItem: async (id, data) => { /* … */ }, deleteItem: async (id) => { /* … */ }, })); ``` ## Frontend page template (canonical) — verified 2026-06-12 A new page needs **five** coordinated edits, not just one file. Skipping any of them is a defect. The frontend team has shipped this checklist twice (exams module, then the 2026-06-12 attendance + front-office extension) — it works. 1. **Store** at `client/src/store/.ts` (shape above). 2. **Page** at `client/src/pages//.tsx` (or cross-role under `client/src/pages/`). Controlled inputs with `useState`; no new form lib. 3. **Route** in `client/src/App.tsx` — add the `{ path, element, roles: [...] }` entry inside the correct role's `switch` arm of `getRoutes()`. `ProtectedRoute` is the role gate; do **not** check `user.role` inside the page. 4. **Nav entry** in `client/src/components/Nav.tsx` — add to the matching `NAV_CONFIG.` array(s). Icons from `lucide-react`; no new icon lib. 5. **Changelog** entry in `.harness/changelogs/YYYY-MM-DD-.md` listing every new file + the roles that see each route. Commit it on the branch. If a page is used by more than one role, extract the shared view into `client/src/components/` and have each role's page be a thin wrapper that loads the role-specific data source (e.g. teacher loads all students via `/api/users?role=student`; parent loads linked children via `/api/users/children`). ## Parallel multi-context store pattern (unified slice, context-routed) When the same operation (take attendance, list records, …) has to hit different backend endpoints depending on a runtime context, **don't** create one slice per context. Create a single slice that takes a `context` enum and routes internally: ```ts type AttendanceContext = 'class' | 'hostel' | 'transport' | 'club'; const baseFor = (ctx: AttendanceContext): string => ctx === 'class' ? '/attendance' : `/${ctx}-attendance`; // fetchRoster / submitBulkAttendance / fetchHistory all switch on ctx // and call api.get/post(`${baseFor(ctx)}/...`). ``` Reference: `client/src/store/attendance.ts` (2026-06-12, attendance + front-office extension). Pair with the multi-tab page pattern (single page, top tabs that swap the picker + roster source + submit endpoint). ## Dev port drift (corrected 2026-06-12) `vite.config.ts` in this repo runs the dev server on port **3000**, not the 5173 mentioned earlier in this file. The API still lives on 3001. Update any other doc or tool that still says 5173. ## Auth + RBAC - JWT signed with `process.env.JWT_SECRET` (default for dev only, loud warning). - 7-day expiry. - Roles: `admin`, `teacher`, `student`, `parent`, `accountant`, `librarian`, `nurse`. - Backend: every protected route goes through `auth` middleware; admin-only writes guard with `req.user.role !== 'admin'`. - Frontend: route gating in `App.tsx` via `` — do not gate inside pages. ## Dev commands ```bash # Backend cd server npm install npm run db:init # runs server/src/database/init.js (idempotent) npm run dev # nodemon, port 3001 # Frontend cd client npm install npm run dev # Vite, port 5173 (proxies /api → localhost:3001) npm run build # production build → client/dist/ npm run preview # serve built bundle # Full stack (production-like) docker-compose up --build ``` ## Sync engine contract - Singleton: `getSyncEngine()`. - Default interval: 30s (`SYNC_INTERVAL` env var, in ms). - Tables listed in `tablesToSync` (server/src/services/SyncEngine.js), in FK dependency order. - Push: `sync_status = 'pending' AND is_deleted = 0`, limit 100/table. - Delete: `is_deleted = 1 AND sync_status = 'pending'`, limit 50/table. - Pull: `updated_at >= last_sync`. - Conflict policy: server-wins, always. - Offline mode: empty `SUPABASE_KEY` → cycle no-ops, app keeps working. - HTTP helper: raw `http`/`https` (no `fetch`/`axios`), 10s timeout. ## Payments (Paynow) - Endpoints: `POST /api/payments/initiate`, `GET /api/payments/status/:id`, `POST /api/payments/webhook`, `GET /api/payments/student/:id`, `DELETE /api/payments/:id`. - Webhook handler is idempotent (Paynow retries). - Payment + `student_fees.status` update wrapped in `db.transaction(() => { … })()`. - Env vars: `PAYNOW_INTEGRATION_ID`, `PAYNOW_INTEGRATION_KEY`, `PAYNOW_RETURN_URL`, `PAYNOW_BLOCKING_URL`. ## Env vars (`.env.example` is the source of truth) ```env PORT=3001 JWT_SECRET=change-me-in-production DB_PATH=./data/school.db SUPABASE_URL=https://api.next_gen.techarvest.co.zw SUPABASE_KEY= SYNC_INTERVAL=30000 PAYNOW_INTEGRATION_ID= PAYNOW_INTEGRATION_KEY= PAYNOW_RETURN_URL=http://localhost:3000/fees PAYNOW_BLOCKING_URL=http://localhost:3000/api/payments/webhook ``` `.env` is gitignored. Never commit it. ## Roles to remember (the people, not the agents) - **admin** — full access, user/department management, reports, settings. - **teacher** — own classes, attendance, assignments, grades for assigned students. - **student** — own courses, assignments, grades, attendance read. - **parent** — own child's academic progress, attendance, messages, fees, calendar. - **accountant / librarian / nurse** — role-specific scopes; consult schema for column-level access.