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

9.8 KiB

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)

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)

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:

app.use('/api/<x>', require('./controllers/<x>.controller'));

Alphabetical order, please.

Frontend store template (canonical)

// 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:

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

# 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)

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.