248 lines
9.8 KiB
Markdown
248 lines
9.8 KiB
Markdown
# 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/<x>', require('./controllers/<x>.controller'));
|
|
```
|
|
|
|
Alphabetical order, please.
|
|
|
|
## Frontend store template (canonical)
|
|
|
|
```ts
|
|
// client/src/store/<x>.ts
|
|
import { create } from 'zustand';
|
|
import { api } from './api';
|
|
|
|
interface XStore {
|
|
items: any[];
|
|
currentItem: any | null;
|
|
fetchItems: (filters?: any) => Promise<void>;
|
|
createItem: (data: any) => Promise<any>;
|
|
updateItem: (id: number, data: any) => Promise<void>;
|
|
deleteItem: (id: number) => Promise<void>;
|
|
}
|
|
|
|
export const useXStore = create<XStore>((set, get) => ({
|
|
items: [],
|
|
currentItem: null,
|
|
|
|
fetchItems: async (filters) => {
|
|
const params = new URLSearchParams(filters || {});
|
|
const { data } = await api.get(`/<x>?${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/<x>.ts` (shape above).
|
|
2. **Page** at `client/src/pages/<role>/<Page>.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.<role>` array(s). Icons from `lucide-react`; no new icon lib.
|
|
5. **Changelog** entry in `.harness/changelogs/YYYY-MM-DD-<topic>.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 `<ProtectedRoute allowedRoles={[…]}>` — 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.
|