Documentation

Ikonex Academy School management system. Full breakdown below or select a component from the sidebar.

Ikonex Academy

School management system — student records, assessments, attendance, report cards, and grading.

Tech Stack

LayerTechnology
BackendNode.js, Express
FrontendReact 18, TypeScript, Vite, Tailwind CSS
DatabasePostgreSQL 16
CMS / APIDirectus (headless CMS)
AuthJWT (access + refresh tokens), bcrypt
PDFPDFKit
ChartsRecharts

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:

Directus Setup

After first launch, configure Directus collections through the admin panel at /admin/settings/data-model:

  1. Create the required collections: students, subjects, class_streams, class_subjects, assessments, grading_scale, attendance, system_logs, and a custom users collection
  2. Create a Static Token for the backend service account under Settings → API Tokens and set it as DIRECTUS_TOKEN in .env
  3. Set user roles and permissions so the backend token has full CRUD access to all items/* endpoints
  4. Ensure the users collection 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

VariableDescriptionDefault
API_PORTBackend port9002
PORTDirectus port9000
DIRECTUS_URLDirectus internal URLhttp://directus:9000
DIRECTUS_TOKENDirectus API static token(required)
JWT_SECRETSecret for signing auth tokens(required)
CORS_ORIGINAllowed CORS originhttp://localhost:9001
VITE_API_URLFrontend API proxy targethttp://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 localStorage
  • logout() — clears localStorage and redirects to /login
  • updateUser(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 icon
  • logo.png — School logo used in the landing page and sidebar
  • vite.svg — Default Vite favicon
  • hero.webp — Hero banner image on the landing page
  • auth.webp — Image used on login/signup pages
  • form.webp, 1713369.webp, 1720403.webp — Decorative images

Dockerfile (frontend)

frontend/Dockerfile uses a multi-stage build:

  1. Build stage: Based on node:18-alpine. Installs dependencies with npm ci, then runs npm run build to produce the production bundle in dist/.
  2. Production stage: Based on nginx:alpine. Copies the built dist/ to /usr/share/nginx/html and includes a custom nginx.conf that serves the SPA with proper history API fallback (all routes serve index.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 HMR
  • npm run build — Production build to dist/
  • npm run typecheck — Runs tsc --noEmit to check for TypeScript errors without emitting compiled files
  • npm run lint — ESLint check

vite.config.ts

Vite configuration file. Key settings:

  • Server port: 9001
  • Proxy: All requests to /api are proxied to http://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-react for 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 JavaScript
  • module: ESNext — Preserves ES module syntax for Vite to handle
  • strict: true — Enables all strict type-checking options
  • jsx: react-jsx — Uses the automatic JSX runtime (no need to import React in every file)
  • moduleResolution: bundler — Modern resolution algorithm compatible with Vite
  • paths: — Maps @/* to src/* for clean imports

tailwind.config.js

Tailwind CSS configuration. Key settings:

  • darkMode: 'class' — Dark mode is activated by adding the .dark class 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 .dark block 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 (the dist/ 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 at http://backend:9002
  • Static caching: Sets Cache-Control headers 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:

  1. Load environment — Calls require('dotenv').config() to load variables from .env
  2. Create Express appconst app = express()
  3. Register global middlewarehelmet() (security headers), cors() (cross-origin requests from the frontend), morgan() (request logging), express.json() (JSON body parsing with 10kb limit), cookieParser(), express.static() (serves the public/ folder for CSS/JS/images)
  4. Configure view engine — Sets EJS as the template engine and views/ as the template directory
  5. Health checkGET /health returns { status: 'ok', message: 'Ikonex Academy API is running' }
  6. Mount route modules — Each resource (auth, class-streams, students, subjects, assessments, reports, system, attendance, docs) has its route module mounted under /api/:resource or at the root for docs
  7. 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
  8. Start serverapp.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 (from API_PORT, BACKEND_PORT, or PORT, default 9002)
  • nodeEnvdevelopment or production
  • directus.url — Directus instance URL (default http://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 (default 15m)
  • cors.origin — Allowed CORS origin (default http://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 the Bearer token from the Authorization header. Verifies it using jsonwebtoken.verify() against the configured JWT_SECRET and issuer (ikonex-academy-api). If valid, sets req.user = { id, email, role }. If invalid or missing, returns 401.
  • authorize(...roles) — Factory function that returns middleware checking if req.user.role is in the allowed roles list. Returns 403 if unauthorized.
  • requireRole(...roles) — Convenience function that chains authenticate + authorize into 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 where lowerBound <= 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 (#141517 background, #212529 sidebar, #0d7377 primary).
  • 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:

  1. Database migration scripts for every schema change,
  2. CRUD endpoints for every collection (students, subjects, assessments, attendance, etc.),
  3. An admin panel for data browsing and schema management,
  4. An authentication system with user management,
  5. A permission system for role-based access.
Directus provides all of this out of the box. The Express backend only needs to implement the business logic that Directus cannot express (grade computation, PDF generation, ranking).

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:

FieldTypeNotes
iduuid (PK, auto)
first_namestring
last_namestring
emailstring (unique)Used for login
passwordstring (hash)bcrypt-hashed by the backend when creating users
rolestringadmin, teacher, or pending. New signups default to pending.
assignedClassesJSONArray of class stream IDs. Determines which classes a teacher can access.
suspendbooleantrue = account deactivated

2. class_streams

Academic classes / streams (e.g. "Form 1A", "Grade 2 East").

FieldTypeNotes
iduuid (PK, auto)
namestringDisplay name (e.g. "Form 3 West")
codestring (unique)Short code (e.g. "F3W")
descriptionstring (optional)

Relationships: students — one-to-many (students → class_streams)

3. students

Student profiles with personal and guardian information.

FieldTypeNotes
iduuid (PK, auto)
admissionNumberstring (unique)Unique student identifier
firstNamestring
lastNamestring
genderstringmale or female
dateOfBirthdate
addressstring (optional)
phoneNumberstring (optional)
parentNamestring (optional)
parentPhonestring (optional)
parentEmailstring (optional)
medicalInfotext (optional)Allergies, conditions, medications
classStreamIduuid (m2o)Relation to class_streams
enrollmentDatedate (optional)Defaults to creation date
isActivebooleanDefault true

Relationships: classStreamIdclass_streams (many-to-one). assessments — one-to-many (assessments → students).

4. subjects

Academic subjects offered at the school (e.g. "Mathematics", "English", "Science").

FieldTypeNotes
iduuid (PK, auto)
namestring
codestring (unique)Short code (e.g. "MATH")
descriptionstring (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.

FieldTypeNotes
iduuid (PK, auto)
classStreamIduuid (m2o)Relation to class_streams
subjectIduuid (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.

FieldTypeNotes
iduuid (PK, auto)
studentIduuid (m2o)Relation to students
subjectIduuid (m2o)Relation to subjects
termstringterm1, term2, or term3
academicYearstringe.g. "2025/2026"
typestringexam or continuous_assessment
scoredecimalThe raw score achieved
maxScoredecimalDefault 100
remarkstext (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.

FieldTypeNotes
iduuid (PK, auto)
gradestringe.g. "A", "B+", "C"
lowerBoundintegerMinimum percentage (inclusive)
upperBoundintegerMaximum percentage (inclusive)
gradePointdecimalNumeric grade point value (e.g. 12, 10, 8)
labelstring (optional)e.g. "Excellent", "Good", "Pass"

Seed data (recommended):

gradelowerBoundupperBoundlabelgradePoint
A80100Excellent12
B+7579Very Good10
B7074Good8
C+6569Fairly Good6
C6064Fair4
D+5559Satisfactory3
D5054Pass2
E4049Weak Pass1
F039Fail0

8. attendance

Daily attendance records for each student. One record per student per day.

FieldTypeNotes
iduuid (PK, auto)
studentIduuid (m2o)Relation to students
classStreamIduuid (m2o)Relation to class_streams
datedateThe date of the attendance record
statusstringpresent or absent
termstringterm1, term2, or term3
academicYearstringe.g. "2025/2026"
isLastDaybooleanFlag indicating this date is the last school day of the term
markedByuuid (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.

FieldTypeNotes
iduuid (PK, auto)
actionstringe.g. "login", "signup", "update_role"
descriptiontextHuman-readable description of what happened
levelstringinfo, warn, or error
userIduuid (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.email from the JWT payload
  • Looks up the teacher's assignedClasses array 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.

ColumnTypeConstraints
iduuidPK, default gen_random_uuid()
first_namevarchar(255)NOT NULL
last_namevarchar(255)NOT NULL
emailvarchar(255)UNIQUE, NOT NULL
passwordvarchar(255)NOT NULL (bcrypt hash)
rolevarchar(50)DEFAULT 'pending'
assignedClassesjsonbDEFAULT '[]'
suspendbooleanDEFAULT false

2. class_streams

ColumnTypeConstraints
iduuidPK
namevarchar(255)NOT NULL
codevarchar(50)UNIQUE, NOT NULL
descriptiontextNULLABLE

3. students

ColumnTypeConstraints
iduuidPK
admissionNumbervarchar(50)UNIQUE, NOT NULL
firstNamevarchar(255)NOT NULL
lastNamevarchar(255)NOT NULL
gendervarchar(10)NOT NULL
dateOfBirthdateNULLABLE
addresstextNULLABLE
phoneNumbervarchar(50)NULLABLE
parentNamevarchar(255)NULLABLE
parentPhonevarchar(50)NULLABLE
parentEmailvarchar(255)NULLABLE
medicalInfotextNULLABLE
classStreamIduuidFK → class_streams(id), NULLABLE
enrollmentDatedateDEFAULT CURRENT_DATE
isActivebooleanDEFAULT true

Index: idx_students_classStreamId ON students(classStreamId)

4. subjects

ColumnTypeConstraints
iduuidPK
namevarchar(255)NOT NULL
codevarchar(50)UNIQUE, NOT NULL
descriptiontextNULLABLE

5. class_subjects (Junction Table)

ColumnTypeConstraints
iduuidPK
classStreamIduuidFK → class_streams(id), NOT NULL
subjectIduuidFK → subjects(id), NOT NULL

Unique constraint: UNIQUE(classStreamId, subjectId)

6. assessments

ColumnTypeConstraints
iduuidPK
studentIduuidFK → students(id), NOT NULL
subjectIduuidFK → subjects(id), NOT NULL
termvarchar(20)NOT NULL
academicYearvarchar(20)NOT NULL
typevarchar(30)NOT NULL
scoredecimal(10,2)NOT NULL
maxScoredecimal(10,2)DEFAULT 100
remarkstextNULLABLE

Unique constraint: UNIQUE(studentId, subjectId, term, academicYear, type)

Indexes: idx_assessments_studentId ON assessments(studentId), idx_assessments_subjectId ON assessments(subjectId)

7. grading_scale

ColumnTypeConstraints
iduuidPK
gradevarchar(10)NOT NULL
lowerBoundintegerNOT NULL
upperBoundintegerNOT NULL
gradePointdecimal(5,2)NOT NULL
labelvarchar(100)NULLABLE

8. attendance

ColumnTypeConstraints
iduuidPK
studentIduuidFK → students(id), NOT NULL
classStreamIduuidFK → class_streams(id), NOT NULL
datedateNOT NULL
statusvarchar(10)NOT NULL
termvarchar(20)NOT NULL
academicYearvarchar(20)NOT NULL
isLastDaybooleanDEFAULT false
markedByuuidFK → 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

ColumnTypeConstraints
iduuidPK
actionvarchar(100)NOT NULL
descriptiontextNOT NULL
levelvarchar(10)NOT NULL
userIduuidFK → 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_created and date_updated on 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 assignedClasses as a JSON field on users — 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.