geocrop-platform./apps/nextgen/.harness/docs/conventions.md

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.