Ikonex Academy
School management system — student records, assessments, attendance, report cards, and grading.
Tech Stack
| Layer | Technology |
|---|---|
| Backend | Node.js, Express |
| Frontend | React 18, TypeScript, Vite, Tailwind CSS |
| Database | PostgreSQL 16 |
| CMS / API | Directus (headless CMS) |
| Auth | JWT (access + refresh tokens), bcrypt |
| PDFKit | |
| Charts | Recharts |
Architecture
frontend:9001 → backend:9002 → directus:9000 → postgres:5432
- Frontend — React SPA, talks to the backend via REST
- Backend — Express API that proxies Directus and adds business logic (grading, scoring, PDF generation)
- Directus — Data layer / admin panel (manages collections, users, permissions)
- PostgreSQL — Primary database
All services run via Docker Compose.
Quick Start
# 1. Clone repository
git clone https://github.com/louisotieno2001/Ikonex-academy.git
# 2. Enter the project
cd ikonex-academy
# 3. Copy environment file and edit secrets (tokens, passwords)
cp .env.example .env
# 4. Start all services
docker compose up -d --build(or with privileges as per your setup)
This starts PostgreSQL, Directus (port 9000), the Express backend (port 9002), and the React frontend (port 9001).
Wait ~30 seconds for Directus to finish its first-time setup, then:
- Frontend — http://localhost:9001
- Directus Admin — http://localhost:9000/admin (login with
ADMIN_EMAIL/ADMIN_PASSWORDfrom.env) - Backend health — http://localhost:9002/health
Directus Setup
After first launch, configure Directus collections through the admin panel at /admin/settings/data-model:
- Create the required collections:
students,subjects,class_streams,class_subjects,assessments,grading_scale,attendance,system_logs, and a customuserscollection - Create a Static Token for the backend service account under Settings → API Tokens and set it as
DIRECTUS_TOKENin.env - Set user roles and permissions so the backend token has full CRUD access to all
items/*endpoints - Ensure the
userscollection has fields:firstName,lastName,email(unique),password(string),role(string),assignedClasses(JSON),suspend(boolean)
Refer to the Directus → Collections pane in the sidebar for detailed field schemas.
Faster Setup Using Snapshot File
Alternatively, you can apply the provided snapshot.yaml file in the root of the project to the directus service to automatically create all collections, fields, and permissions. To do this:
docker cp snapshot.yaml directus:/snapshot.yaml
docker exec -it directus sh -c "npx directus snapshot apply /snapshot.yaml"
Development (without Docker)
# Backend
cd backend
npm install
cp ../.env .
npm run dev # nodemon on port 9002
# Frontend
cd frontend
npm install
npm run dev # Vite on port 9001, proxies /api → localhost:9002
# Type check (frontend)
npm run typecheck
Environment Variables
| Variable | Description | Default |
|---|---|---|
API_PORT | Backend port | 9002 |
PORT | Directus port | 9000 |
DIRECTUS_URL | Directus internal URL | http://directus:9000 |
DIRECTUS_TOKEN | Directus API static token | (required) |
JWT_SECRET | Secret for signing auth tokens | (required) |
CORS_ORIGIN | Allowed CORS origin | http://localhost:9001 |
VITE_API_URL | Frontend API proxy target | http://localhost:9002/api |
Key Features
- Student & Class Management — CRUD for students, class streams, and subjects
- Assessments — Record exam and continuous assessment scores per student/subject
- Grading — Configurable grading scales with automatic grade computation (50/50 exam/CA split)
- Report Cards — Per-student PDF report cards and class-wide performance reports
- Attendance — Daily present/absent tracking with term/date/student filters
- Position Ranking — Class-wide and per-subject position calculation using percentage scores
- Role-based Access — Admin (full access) and Teacher (assigned classes only)
- System Monitoring — Live telemetry with Directus connection status, uptime, and recent signups
Frontend — Introduction
The frontend is a React 18 single-page application (SPA) written in TypeScript, built with Vite, and styled with Tailwind CSS. It lives in the frontend/ directory at the project root and communicates with the Express backend exclusively through typed API client modules.
There is no server-side rendering — the entire UI is client-rendered React. All data fetching is managed by TanStack Query (formerly React Query), which provides caching, background refetching, and optimistic updates. The app uses React Router v6 for client-side routing with a ProtectedRoute wrapper that checks for a valid JWT before rendering authenticated pages.
Tech Justification
React 18 over Vue, Svelte, or Angular
React has the largest ecosystem, best TypeScript integration, and the most community resources. I was already familiar with React, eliminating the learning curve. React 18's automatic batching and concurrent features provide performance improvements without API changes. The component model maps naturally to the school management domain (a page is a component, a table is a component, a form is a component).
TypeScript over JavaScript
The backend returns structured JSON responses (student records, assessment arrays, attendance objects). TypeScript interfaces for these responses catch mismatches at compile time: if the backend changes a field name, the TypeScript compiler immediately shows every frontend location that references it. This is invaluable for a project with 9+ API client modules and 40+ components.
Vite over Create React App (CRA) or Next.js
Vite's native ESM dev server starts instantly (under 1 second) and provides hot module replacement that doesn't degrade as the codebase grows. CRA was the previous standard but suffers from slow rebuilds on larger projects. Next.js was considered unnecessary because this is a pure SPA there is no SEO requirement, no SSR need, and no server-side data fetching that would justify Next.js's complexity.
Tailwind CSS over styled-components or CSS Modules
Tailwind's utility-first approach means styles are colocated with markup, reducing context switching. The design system (colors, spacing, typography) is defined once in tailwind.config.js and enforced across every component. Dark mode is handled by a single .dark class on <html> that overrides CSS custom properties — no runtime CSS-in-JS overhead. Inspiration drawn from Tweakcn( https://tweakcn.com/ )
TanStack Query over Redux or manual state management
TanStack Query handles server state (data fetched from the API) with automatic caching, background refetching, and request deduplication. If two components request the same list of students, TanStack Query sends only one HTTP request. Mutations (create/update/delete) automatically invalidate related queries to keep the UI consistent. This eliminates the boilerplate of Redux reducers, action creators, and manual loading/error state tracking.
Axios over fetch()
Axios provides request interceptors (used to attach the JWT Bearer token to every request automatically) and response interceptors (used to catch 401 responses and redirect to login). It automatically parses JSON responses and provides a cleaner error handling API than the native fetch().
App.tsx
frontend/src/App.tsx is the root React component. It defines the entire route tree using React Router v6 <Routes> and <Route> components.
Public routes (no authentication required):
/— Landing page (Home component) with branding and login/signup calls-to-action/login— Login form with email/password fields/signup— Registration form for new teacher accounts/approval— Pending approval notice for newly registered users
Protected routes (wrapped in <ProtectedRoute>):
/dashboard— Role-specific dashboard with stats, charts, quick actions/students— Student list with search/filter/students/:id— Individual student detail and assessment history/class-streams— All class streams in grid view/class-streams/:id— Single class with students and subjects/subjects— Subject CRUD with class assignment/assessments— Exam and CA score entry with filtering/attendance— Daily attendance marking with history panel/reports— Report card viewer and grading scale management/users— User management (admin only)/logs— System audit log viewer (admin only)
ProtectedRoute is a wrapper component that checks localStorage for a stored JWT token. If no token is found, it redirects to /login. If a token exists but the user's role is pending, it redirects to /approval. The protected dashboard routes are nested under a Layout component that renders the sidebar and header shell.
components/
frontend/src/components/ contains shared UI components used across multiple pages.
Layout.tsx
The main dashboard shell. It renders a sticky sidebar on the left (desktop) or overlay (mobile), a top header bar with breadcrumb navigation and a dark/light theme toggle, and an <Outlet /> where the active route's page component is rendered. The sidebar items are filtered by the user's role — teachers see only their relevant sections, admins see everything plus management pages. The sidebar is lg:sticky lg:top-0 lg:h-screen so it stays fixed while the content scrolls.
The components/ folder may also contain smaller reusable elements like table wrappers, form inputs, modal dialogs, and badge components, all following the same Tailwind + dark-mode pattern.
contexts/
frontend/src/contexts/ contains React Context providers that manage global application state available to every component in the tree.
AuthContext.tsx
Manages authentication state. On mount, it checks localStorage for a stored user object and JWT token. If found, it calls GET /api/auth/me to verify the token is still valid. Provides the following to all children via useContext(AuthContext):
user— the current user object (null if not authenticated)login(email, password)— calls POST /api/auth/login, stores the returned user + token in localStoragelogout()— clears localStorage and redirects to /loginupdateUser(data)— updates the stored user object (used after profile edits)
ThemeContext.tsx
Manages dark/light mode preference. On mount, it reads from localStorage (key: ikonex-theme), falling back to the browser's prefers-color-scheme: dark media query. Toggling the theme applies or removes the .dark class on document.documentElement, which triggers Tailwind's dark mode variant selectors. The preference is persisted to localStorage so it survives page reloads.
api/
frontend/src/api/ contains one module per backend resource. Each module exports a TypeScript interface defining the resource shape and an object of functions that make HTTP requests to the corresponding backend endpoints.
client.ts
The base Axios instance. It reads VITE_API_URL from environment (defaulting to /api in development, which the Vite proxy forwards to the backend). A request interceptor attaches the JWT Bearer token from localStorage to every outgoing request. A response interceptor catches 401 responses and redirects to /login, effectively logging the user out if their token expires.
auth.ts
Handles POST /api/auth/login, POST /api/auth/register, GET /api/auth/me. Also includes admin-only user management: GET /api/auth/users (list all users), PATCH /api/auth/users/:id (update role or suspend), DELETE /api/auth/users/:id.
students.ts
Student CRUD: getAll(params?), getById(id), create(data), update(id, data), delete(id). The params object supports filtering by classStreamId and search (name search).
assessments.ts
Assessment CRUD: getAll(params?) with filters for classStreamId, subjectId, term, academicYear, type (exam or continuous_assessment). create(data) and update(id, data) for score entry. delete(id).
attendance.ts
Attendance operations: getAll(params?) (list records with class/date/term filters), mark(data) (bulk upsert — sends an array of {studentId, status} records for a given date), getDates(params?) (returns unique dates with attendance stats), getStats(params?) (aggregate present/absent counts for dashboard).
subjects.ts
Subject CRUD: getAll(), create(), update(), delete(). Plus assignToClass(subjectId, classStreamId) and unassignFromClass(subjectId, classStreamId) for the many-to-many relationship.
classStreams.ts
Class stream CRUD: getAll(), getById(id), create(), update(), delete().
reports.ts
Report and grading operations: getStudentReport(id, params?) (returns JSON with scores, grades, position), getStudentReportPdf(id, params?) (downloads PDF blob), getClassReport(classId, params?), getClassReportPdf(classId, params?). Also grading scale CRUD: getGradingScales(), createGradingScale(), updateGradingScale(), deleteGradingScale().
system.ts
System operations: getStatus() (Directus connectivity, entity counts, uptime), getLogs(params?) (paginated audit log with action/level/date filters).
modules/
frontend/src/modules/ contains one directory per feature. Each module has an index.ts that re-exports the page component and a pages/ subdirectory with the actual React component implementation.
auth/
LoginPage — Email/password form with validation, calls AuthContext.login(), redirects to /dashboard on success. Shows error toast on failed login.
SignupPage — Registration form (name, email, password, confirm password). Calls POST /api/auth/register, redirects to /approval.
ApprovalPage — Informational page shown to newly registered users whose accounts are pending admin approval.
UserManagementPage — Admin-only table of all users. Each row shows name, email, role, status. Admin can change roles (admin/teacher/pending) or suspend/activate accounts.
dashboard/
DashboardPage — Role-specific landing page after login. For teachers: shows their assigned classes, total students count, attendance stats (present percentage for today), scores recorded count, quick action buttons (Mark Attendance, Enter Scores). For admins: adds system-wide telemetry (Directus status, total users/students, recent signups). Uses Recharts for a class distribution bar chart.
students/
StudentsPage — Paginated table of all students with search-by-name and filter-by-class. Each row has Edit/Delete actions. "Add Student" button opens a modal form.
StudentDetailPage — Full student profile with personal info, parent/guardian details, enrollment date, and a history table of all assessments (exam and CA scores) grouped by term.
class-streams/
ClassStreamsPage — Grid of all class streams, each card showing the class name, code, and student count.
ClassStreamDetailPage — Single class view with three sections: assigned students (table with search), assigned subjects (list with add/remove), and assigned teacher.
subjects/
SubjectsPage — CRUD table of all subjects with name, code, status fields. Each row has an "Assign to Classes" action that opens a multi-select modal of class streams.
assessments/
AssessmentsPage — Filterable table (by class, subject, term, academic year, type) showing all exam and CA scores. "Add Record" button opens a form to enter a score for a student/subject/type combination. Prevents duplicate entries for the same student+subject+term+year+type. Supports student name search client-side.
attendance/
AttendancePage — Date picker to select a day, then displays all students in the selected class with Present/Absent toggle buttons. Bulk actions: "All Present" and "All Absent" set all students at once. "Mark as Last Day" checkbox flags the date as the last school day. Save button submits all changes in one request. Collapsible history panel shows past attendance records with date range filter and student name search.
reports/
ReportsPage — Tabbed interface: "Class Analysis" (class-wide report with per-student scores and positions), "Report Cards" (individual student report with PDF download button), "Grading Scales" (CRUD table for grade boundaries A/B/C/F with percentage ranges and grade points).
system/
LogsPage — Filterable audit log table with columns: timestamp, user, action, description, level (info/warn/error). Supports date range, action type, and level filters. Admin only.
pages/
Home.tsx
The public landing page at GET /. Shown to unauthenticated visitors. Displays the Ikonex Academy branding (school name, tagline, hero image), feature highlights, and calls-to-action for login and signup. If the user is already authenticated (has a stored JWT), it redirects to /dashboard automatically.
public/
frontend/public/ contains static assets served directly by the web server (no Vite processing applied).
favicon.svg— Browser tab iconlogo.png— School logo used in the landing page and sidebarvite.svg— Default Vite faviconhero.webp— Hero banner image on the landing pageauth.webp— Image used on login/signup pagesform.webp,1713369.webp,1720403.webp— Decorative images
Dockerfile (frontend)
frontend/Dockerfile uses a multi-stage build:
- Build stage: Based on
node:18-alpine. Installs dependencies withnpm ci, then runsnpm run buildto produce the production bundle indist/. - Production stage: Based on
nginx:alpine. Copies the builtdist/to/usr/share/nginx/htmland includes a customnginx.confthat serves the SPA with proper history API fallback (all routes serveindex.html) and proxies/api/*requests to the backend container.
package.json (frontend)
Defines the frontend's dependencies and scripts. Key dependencies include React 18, React Router v6, TanStack Query, Axios, Recharts, Lucide React, and React Hook Form. Dev dependencies include TypeScript, Vite, Tailwind CSS, PostCSS, Autoprefixer, and ESLint.
Key scripts:
npm run dev— Starts Vite dev server on port 9001 with HMRnpm run build— Production build todist/npm run typecheck— Runstsc --noEmitto check for TypeScript errors without emitting compiled filesnpm run lint— ESLint check
vite.config.ts
Vite configuration file. Key settings:
- Server port: 9001
- Proxy: All requests to
/apiare proxied tohttp://backend:9002(Docker internal hostname). This means during development, the frontend dev server transparently forwards API calls to the backend without CORS issues. - Plugins:
@vitejs/plugin-reactfor React Fast Refresh and JSX transform - Build: Outputs to
dist/with code splitting enabled
tsconfig.json
TypeScript configuration file. Key settings:
target: ES2020— Compiles to modern JavaScriptmodule: ESNext— Preserves ES module syntax for Vite to handlestrict: true— Enables all strict type-checking optionsjsx: react-jsx— Uses the automatic JSX runtime (no need to import React in every file)moduleResolution: bundler— Modern resolution algorithm compatible with Vitepaths:— Maps@/*tosrc/*for clean imports
tailwind.config.js
Tailwind CSS configuration. Key settings:
darkMode: 'class'— Dark mode is activated by adding the.darkclass to<html>(controlled by ThemeContext)content: ['./index.html', './src/**/*.{ts,tsx}']— Scans all source files for class names- Custom colors: Maps Tailwind utility classes to CSS custom properties defined in
index.css. Properties include--background,--foreground,--card,--sidebar,--sidebar-accent, etc. These are overridden in the.darkblock to create the dark theme. - Custom palettes:
ink(gray scale),brand(teal),accent(amber)
nginx.conf
frontend/nginx.conf configures the Nginx web server used in production (the Docker production stage). Key directives:
- Root:
/usr/share/nginx/html(thedist/output from Vite build) - SPA fallback:
try_files $uri $uri/ /index.html— ensures client-side routes (like /dashboard, /students) serve index.html instead of returning 404 - API proxy: Location block for
/api/proxies requests to the backend service athttp://backend:9002 - Static caching: Sets
Cache-Controlheaders for static assets (JS/CSS bundles get immutable cache, HTML gets no-cache) - Gzip: Enables gzip compression for text assets
Backend — Introduction
The backend is an Express.js API server written in JavaScript (Node.js) that acts as a middleware layer between the React frontend and Directus (headless CMS). It lives in the backend/ directory. Instead of the frontend talking to Directus directly, every request passes through this Express server, which adds authentication, authorization, business logic, and data normalization.
This pattern was chosen because Directus, while excellent at CRUD and data management, cannot express custom business logic like grade computation (50/50 exam/CA split), student ranking (competition ranking with ties), or PDF report generation. By placing Express between the frontend and Directus, the backend can orchestrate multi-step operations (e.g., fetch assessments, compute grades, determine position, generate PDF) while Directus handles only data persistence.
The server uses JWT (JSON Web Tokens) for stateless authentication. bcrypt is used for password hashing. PDFKit generates PDF report cards server-side (no browser dependency). EJS powers the documentation pages. All Directus communication goes through a custom directus.service.js module that wraps Axios.
Tech Justification
Express over Fastify, NestJS e.t.c
Express was chosen for its simplicity, zero-configuration setup, and vast ecosystem. The API surface is straightforward. It exposes CRUD endpoints for students, subjects, assessments, attendance, and class streams, plus a few computation endpoints for reports and rankings. Express's minimalist approach means there's no unnecessary abstraction: a controller is a function, a route is a method call, a middleware is a function. This makes the code easy to trace and debug. NestJS was considered too opinionated for a project of this scope. Its module/controller/service/decorator pattern would add boilerplate without proportional benefit. Fastify is faster on paper but the difference is negligible at this scale, and Express has better community support.
Directus proxy pattern (Express ↔ Directus ↔ PostgreSQL)
Rather than embedding database queries directly in the backend, the Express server delegates all data operations to Directus through its REST API. This means the backend never writes raw SQL, never manages connection pools, and never handles schema migrations. Directus provides the admin panel for schema changes, the authentication layer, the permission system, and the file storage API. The Express backend focuses purely on business logic: validating input, computing grades, generating PDFs, and normalizing response shapes for the frontend.
JWT over session-based auth
JWT tokens are stateless. The server does not need to store session data in memory or a database. The token itself contains the user ID, email, and role, so every authenticated request carries all the information needed for authorization. Access tokens are short-lived (15 minutes by default) for security. A longer-lived refresh token (7 days) allows the frontend to obtain new access tokens without requiring the user to re-login. This pattern works well for SPAs where the frontend stores the token in localStorage and sends it with every request via an Axios interceptor.
PDFKit over Puppeteer or wkhtmltopdf
PDFKit generates PDFs programmatically by drawing text, lines, and shapes on a canvas. This avoids the overhead of launching a headless browser (Puppeteer) or rendering HTML to PDF (wkhtmltopdf). For structured documents like report cards, which have a fixed layout with a header table, a subject rows table, a totals row, and signature lines, PDFKit's imperative drawing model maps directly to the output. Puppeteer would be overkill and slower, especially in a containerized environment where Chrome may not be installed.
EJS over React SSR or plain HTML strings
For the documentation pages served at / and /docs, EJS provides server-rendered templates without requiring the React build pipeline. These pages are simple informational content, they don't need reactivity, client-side state, or API calls. EJS templates are compiled once at server start and rendered on each request with the current data (Directus status, etc.), making them efficient and lightweight.
app.js
backend/src/app.js is the Express application entry point. It is the file that node executes to start the server. Its responsibilities, in order:
- Load environment — Calls
require('dotenv').config()to load variables from.env - Create Express app —
const app = express() - Register global middleware —
helmet()(security headers),cors()(cross-origin requests from the frontend),morgan()(request logging),express.json()(JSON body parsing with 10kb limit),cookieParser(),express.static()(serves thepublic/folder for CSS/JS/images) - Configure view engine — Sets EJS as the template engine and
views/as the template directory - Health check —
GET /healthreturns{ status: 'ok', message: 'Ikonex Academy API is running' } - Mount route modules — Each resource (auth, class-streams, students, subjects, assessments, reports, system, attendance, docs) has its route module mounted under
/api/:resourceor at the root for docs - Error handlers — A 404 catch-all middleware returns
{ error: 'Route not found' }and a global error handler returns{ error: 'Internal server error' }with the error logged to console - Start server —
app.listen(config.port)starts listening on the configured port (default 9002)
config/
config/index.js
Loads and validates environment variables. Exports a config object with the following shape:
port— Server port (fromAPI_PORT,BACKEND_PORT, orPORT, default 9002)nodeEnv—developmentorproductiondirectus.url— Directus instance URL (defaulthttp://localhost:9000)directus.token— Static API token for Directus authentication (required)jwt.secret— Secret key for signing JWT tokens (required)jwt.accessExpiry— Access token expiry (default15m)cors.origin— Allowed CORS origin (defaulthttp://localhost:9001)bcrypt.saltRounds— bcrypt salt rounds (12)
Validation: At the bottom of the file, the config checks that directus.token and jwt.secret are present. If either is missing, it throws a Missing required config error at require time, which means the server will not start with a meaningful error message instead of failing later with a cryptic NullPointerException.
controllers/
Each controller is a module that exports handler functions with the Express signature (req, res, next?). Controllers extract data from req.params, req.query, and req.body, call Directus through the service layer, apply business logic, and return JSON responses or stream PDFs.
auth.controller.js
Handles authentication and user management. Login: receives email + password, looks up the user in Directus, compares the password with bcrypt, generates a JWT access token, and returns the token + user object. Register: creates a new user in Directus with role pending, hashes the password with bcrypt. Profile: GET /me returns the authenticated user's data. User management (admin): list all users, update roles, suspend/delete accounts. Writes entries to system_logs for audit trail on signup, login, and user modifications.
students.controller.js
CRUD for student records. Create: validates that admissionNumber is unique before inserting. List: supports filtering by classStreamId and text search across firstName/lastName. For teacher users, the list is automatically scoped to their assignedClasses — they can only see students in classes they teach. Update/Delete: standard operations with the same class-scoping for teachers.
assessments.controller.js
CRUD for exam and continuous assessment scores. Create: prevents duplicate entries for the same studentId + subjectId + term + academicYear + type combination. Validates that score <= maxScore. List: supports filtering by classStreamId, subjectId, term, academicYear, and type (exam or continuous_assessment). Update: recalculates the validation on save.
attendance.controller.js
Manages daily attendance records. List: returns attendance records filtered by class, date range, term, and academic year. Mark: receives an array of { studentId, status } records for a given date and class. Uses bulk upsert logic — if a record exists for that student+date, it updates the status; otherwise it creates a new record. Also accepts isLastDay flag. Dates: returns all unique dates with attendance recorded for a class. Stats: returns aggregate counts (total present, total absent, percentage) for the dashboard.
classStreams.controller.js
CRUD for class streams (e.g., "Form 1A", "Grade 2 East"). List: returns all class streams with a computed studentCount field (queried from the students collection, not from a Directus O2M alias). Create/Update/Delete: restricted to admin users.
subjects.controller.js
CRUD for academic subjects plus assignment management. Create/Update/Delete: standard operations. Assign: links a subject to a class stream by creating a record in the class_subjects junction table. Unassign: removes the link. List by class: returns all subjects assigned to a given class stream.
reports.controller.js
The most logic-heavy controller. Student report (JSON): fetches all assessments for a student in a given term/year, groups them by subject, computes the average score as (exam + ca) / 2 (the 50/50 split), determines the letter grade from the grading scale, and calculates the student's position in the class. Student report (PDF): same computation but streams the result as a PDF generated by services/pdf.js. Class report: aggregates all students' data for a class-wide view. Grading scale CRUD: create, read, update, delete grade boundaries (A/B/C/F with percentage ranges). All grading scale operations invalidate the in-memory cache in services/grades.js.
system.controller.js
Two endpoints. Status: checks Directus connectivity (pings /server/info), returns latency, counts of entities (users, students, assessments), and server uptime. Logs: returns paginated system log entries with filters for action type, level, and date range.
docs.controller.js
Renders the EJS documentation pages. getIndex: fetches Directus health, user count, and student count (all live data passed to the template), then renders index.ejs. getDocs: renders docs.ejs with the full documentation interface.
routes/
Each resource has a corresponding route file in backend/src/routes/. Route files are thin — they create an Express Router, import the controller, define the mapping between HTTP methods/paths and controller functions, and export the router. Authentication is applied at the route level using requireRole(...) which chains JWT verification + role authorization.
Route structure example (students.routes.js):
router.get('/', requireRole('admin', 'teacher'), studentsController.list);
router.get('/:id', requireRole('admin', 'teacher'), studentsController.getById);
router.post('/', requireRole('admin'), studentsController.create);
router.put('/:id', requireRole('admin'), studentsController.update);
router.delete('/:id', requireRole('admin'), studentsController.delete);
Routes are mounted in app.js:
app.use('/api/auth', authRoutes);
app.use('/api/class-streams', classStreamRoutes);
app.use('/api/students', studentRoutes);
app.use('/api/subjects', subjectRoutes);
app.use('/api/assessments', assessmentRoutes);
app.use('/api/reports', reportRoutes);
app.use('/api/system', systemRoutes);
app.use('/api/attendance', attendanceRoutes);
app.use('/', docsRoutes); // / and /docs (no auth required)
middleware/
middleware/auth.js
Contains three middleware functions:
authenticate— Extracts theBearertoken from theAuthorizationheader. Verifies it usingjsonwebtoken.verify()against the configuredJWT_SECRETand issuer (ikonex-academy-api). If valid, setsreq.user = { id, email, role }. If invalid or missing, returns 401.authorize(...roles)— Factory function that returns middleware checking ifreq.user.roleis in the allowed roles list. Returns 403 if unauthorized.requireRole(...roles)— Convenience function that chainsauthenticate+authorizeinto a single middleware call.
services/
services/directus.service.js
The core data access layer. Wraps Axios to communicate with Directus's REST API. Exports five functions: getItems(collection, params?), getItem(collection, id), createItem(collection, data), updateItem(collection, id, data), deleteItem(collection, id). All functions automatically attach the DIRECTUS_TOKEN as a Bearer token and set the Content-Type header to application/json. The getItems function handles complex parameter flattening — nested filter objects (like { studentId: { _eq: uuid } }) are expanded to Directus's query-string format. All requests are logged to the console in development mode for debugging.
services/grades.js
The grading and ranking engine. Maintains an in-memory cache of grading scales fetched from Directus. The cache is populated on the first call to determineGrade() and invalidated whenever a grading scale is created, updated, or deleted (via a call to invalidateGradeCache()).
determineGrade(score, maxScore, scales?)— Converts a raw score to a percentage ((score / maxScore) * 100), then iterates through the grading scales to find the matching grade (the first scale wherelowerBound <= percentage <= upperBound). Returns{ grade, gradePoint, label, percentage }.calculatePosition(scores)— Implements standard competition ranking ("1224" ranking, not "1234"). Tied scores receive the same rank, and the next different score gets the rank that accounts for all previous ties. For example, scores [95, 90, 90, 85] produce ranks [1, 2, 2, 4]. This is the fair ranking method used in most academic contexts.invalidateGradeCache()— Clears the cached grading scales so the next call re-fetches from Directus.
services/pdf.js
PDF generation using PDFKit, a Node.js library that creates PDF documents programmatically. Contains two main functions:
generateReportCard(student, subjects, grades, academicYear, term)— Creates an A4 portrait PDF with: (1) a full-width indigo gradient header containing the school logo box ("IA") and school name, (2) a student info strip (name, class, admission number, term/year), (3) a subject table with columns: Subject, Exam Score, CA Score, Total (50/50), Grade, Position — each row is alternately shaded and grades are color-coded (A=green, B=blue, C=amber, F=red), (4) a totals footer showing overall percentage, grade, and class position, and (5) signature lines for the class teacher and principal. Returns the PDF as a Buffer.generateClassReport(classData)— Creates a class-wide summary table listing all students with their per-subject scores, totals, and positions.
views/
backend/src/views/ contains EJS templates for the documentation interface.
- index.ejs — The landing page served at
GET /. Standalone layout (no sidebar) with Directus status cards, tech stack cards, and an API endpoints table. - docs.ejs — The full documentation page served at
GET /docs. Uses the sidebar + main content layout with 30+ content panes driven by sidebar clicks. - partials/head.ejs — HTML head section with fonts, CSS link, Lucide CDN, and favicon.
- partials/sidebar.ejs — Sidebar navigation with collapsible sections for Frontend, Backend, Directus, and PostgreSQL.
- partials/foot.ejs — Closing HTML tags, JS scripts, and Lucide icon initialization.
public/ (backend)
backend/src/public/ serves static files for the documentation UI. These are served automatically by Express's express.static() middleware configured in app.js.
- css/styles.css — Dark-themed stylesheet (~150 lines). Uses CSS custom properties and direct color values matching the frontend palette (
#141517background,#212529sidebar,#0d7377primary). - js/main.js — Client-side JavaScript for sidebar toggle (mobile), pane switching (
switchPane()), code block copy buttons, and active state management. - images/vite.svg — Favicon copied from the frontend public directory.
- images/logo.png — School logo for the sidebar header.
Dockerfile (backend)
backend/Dockerfile is a single-stage build based on node:18-alpine. It installs production dependencies with npm ci --production, copies the source code, and runs node src/app.js. The container exposes port 9002 and includes a health check that pings http://localhost:9002/health. Memory is limited to 256MB in Docker Compose.
package.json (backend)
Defines backend dependencies and scripts. Production dependencies: express, cors, helmet, morgan, cookie-parser, dotenv, jsonwebtoken, bcrypt, axios, pdfkit, ejs. Dev dependencies: nodemon (for hot-reload during development). Scripts: npm run dev runs nodemon src/app.js, npm start runs node src/app.js.
Directus — Introduction
Directus is an open-source headless CMS that wraps PostgreSQL with a REST API and an admin panel. In this project, Directus serves as the data persistence layer and the admin interface for schema management. It runs as a Docker container on port 9000.
The backend communicates with Directus exclusively through its REST API using a static API token (DIRECTUS_TOKEN). This token identifies a Directus admin user and provides full CRUD access to all collections. The frontend never talks to Directus directly — every request goes through the Express backend, which adds authentication, business logic, and data normalization.
Directus auto-generates PostgreSQL tables, indexes, and foreign key constraints based on the collection schema you define in the admin panel. This means you never write SQL migrations — you create collections visually through the Directus admin UI at http://localhost:9000/admin.
Tech Justification
Directus over Strapi
Both are popular headless CMS platforms, but Directus was chosen because: (1) its admin panel is more polished and intuitive for non-technical users (teachers and school administrators may need to browse data), (2) it has first-class PostgreSQL support with native schema reflection, (3) its permission model is simpler and more granular than Strapi's role-based plugin system, and (4) its REST API is cleaner and more predictable — what you see in the admin panel is exactly what you get through the API.
Directus over Sanity or Contentful
Sanity and Contentful are excellent SaaS products, but this project requires self-hosting. The school's data (student records, exam scores, attendance) is sensitive and should not leave the school's infrastructure. Directus is free, open-source, and self-hosted — no per-seat licensing, no data egress fees, and full control over backups and uptime.
Directus over writing custom CRUD code
Without Directus, we would need to write:
- Database migration scripts for every schema change,
- CRUD endpoints for every collection (students, subjects, assessments, attendance, etc.),
- An admin panel for data browsing and schema management,
- An authentication system with user management,
- A permission system for role-based access.
Directus Collections
Each collection must be created manually through the Directus admin panel at /admin/settings/data-model. The backend service account (identified by DIRECTUS_TOKEN) needs full CRUD permissions on all items/* endpoints — create, read, update, delete.
1. users
Collection for user accounts:
| Field | Type | Notes |
|---|---|---|
id | uuid (PK, auto) | |
first_name | string | |
last_name | string | |
email | string (unique) | Used for login |
password | string (hash) | bcrypt-hashed by the backend when creating users |
role | string | admin, teacher, or pending. New signups default to pending. |
assignedClasses | JSON | Array of class stream IDs. Determines which classes a teacher can access. |
suspend | boolean | true = account deactivated |
2. class_streams
Academic classes / streams (e.g. "Form 1A", "Grade 2 East").
| Field | Type | Notes |
|---|---|---|
id | uuid (PK, auto) | |
name | string | Display name (e.g. "Form 3 West") |
code | string (unique) | Short code (e.g. "F3W") |
description | string (optional) |
Relationships: students — one-to-many (students → class_streams)
3. students
Student profiles with personal and guardian information.
| Field | Type | Notes |
|---|---|---|
id | uuid (PK, auto) | |
admissionNumber | string (unique) | Unique student identifier |
firstName | string | |
lastName | string | |
gender | string | male or female |
dateOfBirth | date | |
address | string (optional) | |
phoneNumber | string (optional) | |
parentName | string (optional) | |
parentPhone | string (optional) | |
parentEmail | string (optional) | |
medicalInfo | text (optional) | Allergies, conditions, medications |
classStreamId | uuid (m2o) | Relation to class_streams |
enrollmentDate | date (optional) | Defaults to creation date |
isActive | boolean | Default true |
Relationships: classStreamId → class_streams (many-to-one). assessments — one-to-many (assessments → students).
4. subjects
Academic subjects offered at the school (e.g. "Mathematics", "English", "Science").
| Field | Type | Notes |
|---|---|---|
id | uuid (PK, auto) | |
name | string | |
code | string (unique) | Short code (e.g. "MATH") |
description | string (optional) |
Relationships: Many-to-many with class_streams via class_subjects junction table.
5. class_subjects
Junction table linking subjects to class streams. Determines which subjects are taught in which classes.
| Field | Type | Notes |
|---|---|---|
id | uuid (PK, auto) | |
classStreamId | uuid (m2o) | Relation to class_streams |
subjectId | uuid (m2o) | Relation to subjects |
Uniqueness: [classStreamId + subjectId] should be unique (no duplicate assignments).
Relationships: Many-to-one to both class_streams and subjects.
6. assessments
Stores individual exam and continuous assessment scores. Each record represents one score for one student in one subject for a specific type, term, and academic year.
| Field | Type | Notes |
|---|---|---|
id | uuid (PK, auto) | |
studentId | uuid (m2o) | Relation to students |
subjectId | uuid (m2o) | Relation to subjects |
term | string | term1, term2, or term3 |
academicYear | string | e.g. "2025/2026" |
type | string | exam or continuous_assessment |
score | decimal | The raw score achieved |
maxScore | decimal | Default 100 |
remarks | text (optional) | Teacher comments |
Uniqueness: [studentId + subjectId + term + academicYear + type] — no duplicate entries for the same combination.
7. grading_scale
Defines letter grades and their percentage ranges. The grade scale determines the final grade on report cards.
| Field | Type | Notes |
|---|---|---|
id | uuid (PK, auto) | |
grade | string | e.g. "A", "B+", "C" |
lowerBound | integer | Minimum percentage (inclusive) |
upperBound | integer | Maximum percentage (inclusive) |
gradePoint | decimal | Numeric grade point value (e.g. 12, 10, 8) |
label | string (optional) | e.g. "Excellent", "Good", "Pass" |
Seed data (recommended):
| grade | lowerBound | upperBound | label | gradePoint |
|---|---|---|---|---|
| A | 80 | 100 | Excellent | 12 |
| B+ | 75 | 79 | Very Good | 10 |
| B | 70 | 74 | Good | 8 |
| C+ | 65 | 69 | Fairly Good | 6 |
| C | 60 | 64 | Fair | 4 |
| D+ | 55 | 59 | Satisfactory | 3 |
| D | 50 | 54 | Pass | 2 |
| E | 40 | 49 | Weak Pass | 1 |
| F | 0 | 39 | Fail | 0 |
8. attendance
Daily attendance records for each student. One record per student per day.
| Field | Type | Notes |
|---|---|---|
id | uuid (PK, auto) | |
studentId | uuid (m2o) | Relation to students |
classStreamId | uuid (m2o) | Relation to class_streams |
date | date | The date of the attendance record |
status | string | present or absent |
term | string | term1, term2, or term3 |
academicYear | string | e.g. "2025/2026" |
isLastDay | boolean | Flag indicating this date is the last school day of the term |
markedBy | uuid (m2o) | Relation to users — the teacher who recorded attendance |
Uniqueness: [studentId + date] — each student has at most one record per day.
9. system_logs
Audit trail for security-relevant events. Written by the backend controllers on signup, login, and user modifications.
| Field | Type | Notes |
|---|---|---|
id | uuid (PK, auto) | |
action | string | e.g. "login", "signup", "update_role" |
description | text | Human-readable description of what happened |
level | string | info, warn, or error |
userId | uuid (m2o) | Relation to users — the user who performed the action |
Relationship Summary
class_streams ──┬── class_subjects ── subjects
│
└── students ── assessments ── subjects
│
└── attendance
│
users ──┘ (markedBy)
grading_scale (standalone, referenced by reports controller)
system_logs ──── users (userId)
Directus Permissions
The permission system has three layers:
1. Backend Service Token
Create a Directus admin user and generate a static API token. Set this token as DIRECTUS_TOKEN in .env. The backend uses this token for all Directus API requests, and it needs full CRUD access to all collections. This token is never exposed to the frontend.
2. Admin Users (role: admin)
Admins access the system through the Express backend (not Directus directly). They can:
- View and manage all students, subjects, classes across the entire school
- Create, update, delete any record
- Manage user accounts (change roles, suspend/unsuspend)
- Access system logs and telemetry
Admin access is enforced by the requireRole('admin') middleware on specific routes (user management, class/subject creation).
3. Teacher Users (role: teacher)
Teachers are scoped to their assigned classes, stored in the assignedClasses JSON array on their users record. When a teacher makes a request, the backend:
- Reads
req.user.emailfrom the JWT payload - Looks up the teacher's
assignedClassesarray from Directus - Filters all queries to only return data belonging to those classes
- Teachers cannot create/update class streams, subjects, or user accounts
4. Pending Users (role: pending)
New registrations default to pending status. These users cannot access any dashboard pages — they are redirected to the /approval page until an admin changes their role to teacher or admin.
Dockerfile (Directus)
Directus runs from the official directus/directus:10 Docker image. It is configured entirely through environment variables in docker-compose.yml. No custom Dockerfile is needed — the image handles everything: database migrations, extensions, file uploads, and the admin panel. The container health check polls http://localhost:9000/server/health until Directus is ready (migrations may take 30-60 seconds on first start). Memory limit: 512MB.
PostgreSQL — Introduction
PostgreSQL 16 is the database engine that stores all application data. It runs in a Docker container on port 5432. Directus manages the schema automatically — you create collections through the Directus admin panel, and Directus generates the PostgreSQL tables, indexes, foreign keys, and constraints.
All data — student profiles, exam scores, attendance records, user accounts, system logs, and grading scales — lives in a single PostgreSQL database. Directus's internal tables (for users, permissions, settings, etc.) share the same database, which simplifies backups and connection management.
Tech Justification
PostgreSQL over MySQL
PostgreSQL was chosen over MySQL for several technical reasons. First, PostgreSQL's JSON support is superior — it provides JSONB (binary JSON) with GIN indexes for efficient querying of JSON fields. This is used for the assignedClasses array on teacher records, which stores class stream IDs as a JSON array and needs to be queried efficiently. Second, PostgreSQL's indexing capabilities are more advanced (partial indexes, expression indexes, covering indexes). Third, PostgreSQL handles concurrent transactions more robustly with its MVCC implementation, which matters when multiple teachers are entering scores simultaneously.
PostgreSQL over MongoDB
The data model is highly relational: students belong to classes, assessments reference both students and subjects, attendance references students and classes, class_subjects is a many-to-many junction. A document store like MongoDB would require either embedding related data (leading to duplication and inconsistency) or performing manual joins in application code. PostgreSQL handles all of this natively with foreign keys, joins, and transactions.
Schema managed by Directus
Directus manages the PostgreSQL schema through its migration system. When you create a collection in the Directus admin panel, Directus generates the CREATE TABLE SQL, creates indexes, sets up foreign key constraints for relationships, and tracks the schema in its internal migrations table. This eliminates the need for standalone SQL migration scripts or an ORM — the schema is always in sync with what you see in the Directus admin panel.
How to Run
docker compose up -d postgres # starts PostgreSQL on port 5432
# Directus auto-migrates schema on startup
Connection settings are configured via environment variables in .env: DB_HOST (default postgres Docker hostname), DB_PORT (5432), DB_DATABASE (default ikonex), DB_USER (default ikonex), DB_PASSWORD.
PostgreSQL Schema
The schema matches the Directus collections exactly — Directus creates and manages the underlying PostgreSQL tables. Below is the complete schema with every table, column, type, and constraint.
1. users (Managed by Directus + custom fields)
Built-in user table extended with custom fields for the school management system.
| Column | Type | Constraints |
|---|---|---|
id | uuid | PK, default gen_random_uuid() |
first_name | varchar(255) | NOT NULL |
last_name | varchar(255) | NOT NULL |
email | varchar(255) | UNIQUE, NOT NULL |
password | varchar(255) | NOT NULL (bcrypt hash) |
role | varchar(50) | DEFAULT 'pending' |
assignedClasses | jsonb | DEFAULT '[]' |
suspend | boolean | DEFAULT false |
2. class_streams
| Column | Type | Constraints |
|---|---|---|
id | uuid | PK |
name | varchar(255) | NOT NULL |
code | varchar(50) | UNIQUE, NOT NULL |
description | text | NULLABLE |
3. students
| Column | Type | Constraints |
|---|---|---|
id | uuid | PK |
admissionNumber | varchar(50) | UNIQUE, NOT NULL |
firstName | varchar(255) | NOT NULL |
lastName | varchar(255) | NOT NULL |
gender | varchar(10) | NOT NULL |
dateOfBirth | date | NULLABLE |
address | text | NULLABLE |
phoneNumber | varchar(50) | NULLABLE |
parentName | varchar(255) | NULLABLE |
parentPhone | varchar(50) | NULLABLE |
parentEmail | varchar(255) | NULLABLE |
medicalInfo | text | NULLABLE |
classStreamId | uuid | FK → class_streams(id), NULLABLE |
enrollmentDate | date | DEFAULT CURRENT_DATE |
isActive | boolean | DEFAULT true |
Index: idx_students_classStreamId ON students(classStreamId)
4. subjects
| Column | Type | Constraints |
|---|---|---|
id | uuid | PK |
name | varchar(255) | NOT NULL |
code | varchar(50) | UNIQUE, NOT NULL |
description | text | NULLABLE |
5. class_subjects (Junction Table)
| Column | Type | Constraints |
|---|---|---|
id | uuid | PK |
classStreamId | uuid | FK → class_streams(id), NOT NULL |
subjectId | uuid | FK → subjects(id), NOT NULL |
Unique constraint: UNIQUE(classStreamId, subjectId)
6. assessments
| Column | Type | Constraints |
|---|---|---|
id | uuid | PK |
studentId | uuid | FK → students(id), NOT NULL |
subjectId | uuid | FK → subjects(id), NOT NULL |
term | varchar(20) | NOT NULL |
academicYear | varchar(20) | NOT NULL |
type | varchar(30) | NOT NULL |
score | decimal(10,2) | NOT NULL |
maxScore | decimal(10,2) | DEFAULT 100 |
remarks | text | NULLABLE |
Unique constraint: UNIQUE(studentId, subjectId, term, academicYear, type)
Indexes: idx_assessments_studentId ON assessments(studentId), idx_assessments_subjectId ON assessments(subjectId)
7. grading_scale
| Column | Type | Constraints |
|---|---|---|
id | uuid | PK |
grade | varchar(10) | NOT NULL |
lowerBound | integer | NOT NULL |
upperBound | integer | NOT NULL |
gradePoint | decimal(5,2) | NOT NULL |
label | varchar(100) | NULLABLE |
8. attendance
| Column | Type | Constraints |
|---|---|---|
id | uuid | PK |
studentId | uuid | FK → students(id), NOT NULL |
classStreamId | uuid | FK → class_streams(id), NOT NULL |
date | date | NOT NULL |
status | varchar(10) | NOT NULL |
term | varchar(20) | NOT NULL |
academicYear | varchar(20) | NOT NULL |
isLastDay | boolean | DEFAULT false |
markedBy | uuid | FK → users(id), NULLABLE |
Unique constraint: UNIQUE(studentId, date)
Indexes: idx_attendance_date ON attendance(date), idx_attendance_classDate ON attendance(classStreamId, date)
9. system_logs
| Column | Type | Constraints |
|---|---|---|
id | uuid | PK |
action | varchar(100) | NOT NULL |
description | text | NOT NULL |
level | varchar(10) | NOT NULL |
userId | uuid | FK → users(id), NULLABLE |
Index: idx_system_logs_action ON system_logs(action)
Relationship Summary
class_streams 1──N students
class_streams N──M subjects (via class_subjects)
students 1──N assessments
subjects 1──N assessments
students 1──N attendance
class_streams 1──N attendance
users 1──N attendance (markedBy)
users 1──N system_logs (userId)
Directus configuration notes:
- Enable
date_createdanddate_updatedon all collections (Directus defaults) - Configure unique constraints in Directus for:
users.email,students.admissionNumber,subjects.code,class_streams.code,assessments[studentId+subjectId+term+academicYear+type],attendance[studentId+date] - Enable
assignedClassesas a JSON field onusers— this stores teacher class assignments as an array of class stream IDs
Dockerfile (PostgreSQL)
PostgreSQL runs from the official postgres:16-alpine Docker image. No custom Dockerfile is needed. The container is configured through environment variables in docker-compose.yml: POSTGRES_DB, POSTGRES_USER, POSTGRES_PASSWORD. The database files are persisted in a named Docker volume (pgdata) so data survives container restarts. Memory limit: 512MB. Health check runs pg_isready -U $POSTGRES_USER.