# i-Learned — Architecture

> Integrated School Management System (MVP prototype)
> Institution reference model: a Philippine college of arts, sciences and health programmes. The seeded tenant, **i-Learned Academy**, is fictional.

This document explains **how** the system is built and **why** each significant
decision was made. It is the companion to `SPEC.md` (what to build) and
`ASSUMPTIONS.md` (gaps that were resolved unilaterally).

---

## 1. Guiding principles

| Principle | How it shows up in the code |
| --- | --- |
| **Server-side authorization is the only authorization** | Every API route resolves a `SecurityContext` and calls `authorize()` before touching data. UI nav hiding is cosmetic only. |
| **Policy is data, not code** | Tuition, fees, academic years, terms, admission thresholds, requirement checklists, grading scales, clearance steps and document types all live in tables and are editable by authorized administrators. |
| **Nothing sensitive is destroyed** | Requirements keep every uploaded version. Grades keep full history. Payments are voided/reversed, never deleted. Audit logs are append-only. |
| **State machines, not status strings** | Application, enrollment, requirement, payment, grade and document-request statuses transition only through declared, tested transition tables. |
| **Minimum necessary data** | Faculty see instruction-relevant fields only. Analytics aggregate and suppress small cells. Logs are redacted. |
| **Typed end to end** | One Zod schema per API contract, shared by the route handler and the client form. Prisma types flow into the service layer. |

---

## 2. Chosen stack

| Concern | Choice | Rationale |
| --- | --- | --- |
| Monorepo | **pnpm workspaces + Turborepo 2** | Content-hashed task caching; strict dependency isolation catches accidental cross-imports. |
| App | **Next.js 16 (App Router) + React 19**, TypeScript strict | `SPEC.md §5` explicitly permits a well-structured Next.js full-stack implementation for the prototype. One deployable unit, one auth model, no cross-service serialization for an MVP whose bottleneck is workflow modelling, not scale. |
| API | **REST under `/api/v1/*`** (Route Handlers) + OpenAPI 3.1 | Versioned from day one, documented, and independent of React Server Component internals. |
| Validation | **Zod 4** | One schema validates the HTTP body and types the client form via `@hookform/resolvers`. |
| Database | **PostgreSQL 17 + Prisma 6** | Relational integrity, `NUMERIC` money, partial/composite unique indexes, transactional workflow steps. |
| Styling | **Tailwind CSS 4** with CSS-variable design tokens, finished in glassmorphism | Branding is runtime-configurable: tokens read from CSS custom properties injected from the `Branding` record — including the ambient wash the frosted panels refract, so the whole atmosphere follows the institution's own colours. Contrast is computed over the blend and asserted in `packages/ui/src/styles/tokens.test.ts`; glass falls back to solid surfaces under `prefers-reduced-transparency`, `forced-colors`, and browsers without `backdrop-filter`. |
| Components | Local **shadcn-style** primitives in `packages/ui` | Radix behaviour + accessible defaults, vendored so we control WCAG details. |
| Server state | **TanStack Query 5** | Cache invalidation on workflow mutations. |
| Tables | **TanStack Table 8** | Server-driven pagination/sorting/filtering for back-office lists. |
| Charts | **Recharts 3** | Dashboards render live query results. |
| Password hashing | **Argon2id** via `@node-rs/argon2` | Required by `SPEC.md §25`. Prebuilt native binaries — no toolchain needed on Windows. |
| Sessions | **Opaque, DB-backed, HttpOnly cookie** | Instantly revocable. Preferred over JWT by `SPEC.md §5`; avoids refresh-token rotation complexity. |
| Tests | **Vitest 4** (unit + integration), **Playwright** (E2E) | Same runner for pure domain logic and API integration. |

### Why not NestJS?

`SPEC.md §5` lists NestJS as *preferred* but allows a Next.js full-stack
implementation **if clean service/repository/domain separation is preserved and
a migration path remains**. We took that option deliberately, and enforced the
separation structurally.

There are three layers, and the boundary that matters is the first one:

| Layer | Where | May import |
| --- | --- | --- |
| **Domain** — decisions | `packages/core` | nothing but itself |
| **Services** — orchestration | `apps/web/src/server/services` | domain + Prisma |
| **Transport** — HTTP | `apps/web/src/app/api/v1` | services |

**The domain layer decides; it never persists.** `packages/core` holds the
permission model, the workflow state machines, money arithmetic, schedule
conflict detection and grading rules. It has **zero** imports from `next`,
`react` or `@prisma/client` — enforced by an ESLint `no-restricted-imports`
rule, not by convention. Every rule in it is testable without a database, which
is why 203 of the unit tests need no fixture at all.

**The service layer orchestrates.** It loads state, asks the domain what is
allowed, and writes the result — status, history and audit entry — inside one
transaction. It uses Prisma directly rather than through repository interfaces:
for a system this size the indirection would add a layer to maintain and remove
none of the coupling, since the services are the only Prisma callers anyway.

**Route handlers are thin.** A handler parses input with Zod, resolves a
`SecurityContext`, calls one service, and serializes. No query logic and no
policy branching lives in a route file.

Migrating to a standalone NestJS API means moving `services/` and writing
controllers that call it. `packages/core` — where all the rules are — moves
unchanged.

---

## 3. Repository layout

```
i-learned/
├── apps/
│   └── web/                     Next.js 16 app — UI + REST API + OpenAPI
│       ├── src/app/
│       │   ├── (public)/        landing, /apply wizard, /apply/status, /login
│       │   ├── (portal)/        role dashboards behind the app shell
│       │   │   ├── admin/  admissions/  dean/
│       │   │   └── registrar/  cashier/  faculty/  student/
│       │   └── api/v1/          versioned REST route handlers
│       ├── src/server/          composition root + orchestration
│       │   ├── services/        load → decide (via core) → persist → audit
│       │   ├── adapters/        port implementations bound by env
│       │   ├── queries/         read models for dashboards and lists
│       │   └── api/             request context, guards, response envelope
│       ├── src/components/      app-specific composed UI
│       └── tests/               integration (Vitest) + e2e (Playwright)
├── packages/
│   ├── core/                    domain layer — framework-free
│   │   ├── src/auth/            permissions, roles, scope resolution
│   │   ├── src/workflow/        state machines + transition guards
│   │   ├── src/money/           integer-centavo arithmetic, assessment calc
│   │   ├── src/scheduling/      conflict detection, load computation
│   │   ├── src/grading/         grade scales, GWA, submission rules
│   │   ├── src/ports/           interfaces: repositories, storage, mail,
│   │   │                        jobs, exam provider, lead ads, payments
│   │   └── src/errors/          typed domain errors → HTTP mapping
│   ├── db/                      Prisma schema, migrations, seed, repositories
│   ├── ui/                      design tokens + accessible primitives
│   └── config/                  shared tsconfig + ESLint flat configs
├── tools/
│   └── local-postgres/          Docker-free PostgreSQL for local dev
└── docker/                      compose init scripts
```

**Dependency rule** (enforced by ESLint `no-restricted-imports` and by the
absence of the dependency in each `package.json`):

```
apps/web  ──▶  packages/db  ──▶  packages/core
    │                              ▲
    └──────────────────────────────┘
packages/ui  ──▶  (nothing but React)
```

`core` depends on nothing in the workspace — it is the sink, not the source.
`db` owns the schema and the adapters that implement `core`'s ports. `web`
wires them together and holds the orchestration services. `ui` knows about
React and nothing else, so a component can never reach a query.

---

## 4. Request lifecycle

```
Browser
  │  cookie: ilearned_session (HttpOnly, SameSite=Lax)
  ▼
middleware.ts                 coarse route gate + security headers
  │                           (never the authorization decision)
  ▼
Route Handler  /api/v1/…
  │  1. rateLimit(request)                 public endpoints only
  │  2. schema.parse(await request.json()) Zod → 422 on failure
  │  3. ctx = await requireSession(request)
  │       └─ loads user, roles, permissions, college/program scope,
  │          faculty section scope, student self scope
  │  4. authorize(ctx, 'grade.publish', { collegeId })   ← throws ForbiddenError
  ▼
Service  (packages/core)
  │  5. domain rules + state-machine transition check
  │  6. repository calls inside one transaction
  ▼
Repository  (packages/db)
  │  7. Prisma queries, always scope-filtered
  ▼
PostgreSQL
  │
  ├─▶ AuditLog        append-only, written in the same transaction
  ├─▶ StatusHistory   from → to, actor, reason, timestamp
  └─▶ Notification    queued through the JobQueue port
```

Errors are caught by a single `withApiHandler` wrapper that maps typed domain
errors onto the standard envelope (§7) and logs a redacted record.

---

## 5. Authorization model

Three independent checks, all server-side. **All three must pass.**

### 5.1 Permission

Permissions are fine-grained strings (`grade.publish`, `payment.void`,
`student.export`). Roles are bundles of permissions stored in
`RolePermission` — not hard-coded — so an administrator can create a new role
without a schema change or a deployment.

The eight seeded roles (`SUPERADMIN`, `ADMIN`, `ADMISSIONS`, `REGISTRAR`,
`DEAN`, `CASHIER`, `FACULTY`, `STUDENT`) are seed data, not enum branches in
business logic. `SUPERADMIN` is the single exception: it short-circuits the
permission check, and every such use is audited.

### 5.2 Resource scope

A permission alone is never sufficient. `SecurityContext` carries the scopes the
actor holds, and every query is filtered by them:

| Role | Scope | Enforcement |
| --- | --- | --- |
| Dean | assigned colleges/programs (`DeanAssignment`) | `WHERE program.collegeId IN (:scope)` |
| Faculty | assigned section offerings (`FacultyAssignment`) | `WHERE sectionOfferingId IN (:scope)` |
| Student | own `studentId` only | `WHERE studentId = ctx.studentId` |
| Cashier | financial records only; no academic mutation permissions | permission set omits `grade.*` |

Scope filters are applied in the repository, so a service can never accidentally
return out-of-scope rows by forgetting a clause.

### 5.3 Field-level projection

Even inside scope, a role receives only the fields it needs. Faculty reading a
class list get name, student number, year level and grade fields — never
financial balances, admission documents, or contact details. Projections are
explicit `select` objects named per audience (`facultyClassListSelect`).

---

## 6. Workflow state machines

Statuses never move by direct assignment. Each workflow declares a transition
table in `packages/core/src/workflow`, and `transition()` rejects any move not
present in it — with the actor's permissions and the required reason checked at
the same time.

```ts
// packages/core/src/workflow/application.ts  (illustrative shape)
export const applicationTransitions: TransitionTable<ApplicationStatus> = {
  SUBMITTED: {
    UNDER_REVIEW:  { permission: 'application.review' },
    WITHDRAWN:     { permission: 'application.manage', requiresReason: true },
  },
  DEAN_INTERVIEW_COMPLETED: {
    ACCEPTED: { permission: 'application.approve' },
    REJECTED: { permission: 'application.approve', requiresReason: true },
  },
  // …
};
```

Every successful transition writes a `*StatusHistory` row (`from`, `to`, actor,
timestamp, reason, metadata) **inside the same transaction** as the status
update, so history can never drift from current state.

Modelled workflows: **Application** (freshman / transferee / second-degree /
medicine variants), **Requirement**, **Enrollment**, **Payment**,
**Grade submission**, **Document request**, **Clearance**, **Lead stage**.

The four admission tracks share one engine and differ only in their configured
step sequence — Medicine is a distinct configured track (NMAT screening, panel
interview, dean interview), never a special case branch in code.

---

## 7. API conventions

Base path `/api/v1`. OpenAPI 3.1 is generated from the Zod schemas and served at
`/api/v1/openapi.json` with reference UI at `/api/docs`.

**Success**

```jsonc
{
  "data": { /* resource or array */ },
  "meta": { "page": 1, "pageSize": 25, "total": 137, "totalPages": 6 }
}
```

**Error**

```jsonc
{
  "error": {
    "code": "FORBIDDEN",             // stable machine-readable code
    "message": "…",                  // safe for display; never leaks existence
    "details": [                     // present for VALIDATION_FAILED
      { "path": "applicant.email", "message": "Invalid email address" }
    ],
    "requestId": "01JQ…"             // correlates with server logs
  }
}
```

Codes: `VALIDATION_FAILED` (422), `UNAUTHENTICATED` (401), `FORBIDDEN` (403),
`NOT_FOUND` (404), `CONFLICT` (409), `PRECONDITION_FAILED` (412),
`RATE_LIMITED` (429), `INTERNAL` (500).

`NOT_FOUND` is deliberately returned instead of `FORBIDDEN` for out-of-scope
resources, so an unauthorized caller cannot probe which records exist
(`SPEC.md §25`).

Payment posting and enrollment finalization accept an `Idempotency-Key` header;
a replayed key returns the original result instead of double-posting.

---

## 8. Data model highlights

The full schema lives in `packages/db/prisma/schema.prisma`. The decisions worth
calling out:

**Person identity is not duplicated.** A `Lead` may become an `ApplicantProfile`,
which may become a `Student`. The applicant record is *linked*, not copied, so
converting an applicant to a student re-encodes nothing and preserves the trail
from first inquiry to enrolment.

**Money is `NUMERIC(14,2)`, never floating point.** In-application arithmetic
uses integer centavos (`packages/core/src/money`) and converts only at the
persistence boundary.

**Curriculum versions are immutable once used.** `CurriculumVersion` carries
effectivity dates. An `Enrollment` snapshots the exact curriculum version,
section, course code, title and units it was created against, so editing a
curriculum in 2027 cannot retroactively change what a 2026 student enrolled in.

**Uploads are versioned.** `ApplicantRequirement` owns many
`RequirementSubmission` rows. A resubmission adds a version; it never overwrites.

**Payments are immutable.** A posted `Payment` is never updated or deleted.
Corrections create a `PaymentReversal` referencing the original, with a required
reason. Balances are computed from the ledger of allocations.

**Grades keep history.** `Grade` holds the current value;
`GradeHistory` retains every prior value with actor and reason, and post-final
edits require an approved `GradeChangeRequest`.

**Audit is append-only.** No service exposes update or delete on `AuditLog`, and
a database rule blocks `UPDATE`/`DELETE` for the application role.

---

## 9. Ports and adapters

Every external dependency sits behind an interface in
`packages/core/src/ports`, with a development implementation that needs **no
credentials**. This satisfies `SPEC.md §5, §12, §17` without blocking local
demonstration.

| Port | Dev implementation | Production path |
| --- | --- | --- |
| `FileStorage` | `LocalDiskStorage` (private dir, HMAC-signed URLs) | `S3Storage` — AWS S3 / R2 / MinIO |
| `Mailer` | `ConsoleMailer` (renders to stdout) | `SmtpMailer` |
| `JobQueue` | `InlineJobQueue` (synchronous) | Redis/BullMQ worker |
| `EntranceExamProvider` | `InternalExamProvider` (schedules held locally) | `ExternalExamProvider` — stores an ILA-CAT booking reference |
| `LeadAdsProvider` | `MockLeadAdsProvider` + CSV import | Meta Graph API Lead Ads sync |
| `PaymentGateway` | `NullPaymentGateway` (throws `NotConfigured`) | Real gateway, pending institutional decision |
| `CaptchaVerifier` | `NoopCaptchaVerifier` | Turnstile / reCAPTCHA |
| `MalwareScanner` | `NoopScanner` (marks `UNSCANNED`) | ClamAV / vendor API |
| `RateLimiter` | in-memory sliding window | Redis-backed |

Adapters are selected by environment variable and bound once in
`apps/web/src/server/container.ts`.

---

## 10. Security controls

- **Argon2id** (`memoryCost 19456 KiB, timeCost 2, parallelism 1`, per OWASP)
  with per-user salt. Password hashes never leave the repository layer.
- **Sessions** are 32 random bytes. The cookie holds the raw token; the database
  stores `SHA-256(token + SESSION_SECRET)`. Idle and absolute timeouts are
  independent. Logout and administrative revocation delete the row, so access
  ends immediately.
- **Brute-force protection** counts failures per identifier *and* per IP and
  locks the account temporarily. Login responses are indistinguishable between
  "no such user" and "wrong password".
- **Password reset** issues a single-use expiring token, stored hashed. Using it
  revokes all existing sessions.
- **Security headers** (CSP, `X-Content-Type-Options`, `Referrer-Policy`,
  `Permissions-Policy`, HSTS in production) are set in `middleware.ts`.
- **CSRF**: `SameSite=Lax` cookies plus an `Origin` check on every mutating
  request.
- **Uploads** are validated on extension, sniffed MIME, and size; stored under
  an opaque UUID key outside the web root; downloaded only through short-lived
  HMAC-signed URLs whose issuance is authorized and audited.
- **Logging** passes through a redactor that strips passwords, tokens, cookies,
  full document contents, and known PII fields before anything is written.

---

## 11. Analytics

Every dashboard number comes from a live SQL aggregate — there are no
hard-coded figures anywhere in the UI. Queries live in
`packages/db/src/analytics` as named, tested functions.

Demographic breakdowns apply **small-cell suppression**: a bucket with fewer
than the configured threshold (default 5, in `SystemSetting`) is reported as
`< 5` rather than an exact count, so cross-tabulating filters cannot isolate an
individual. CSV export requires an explicit `*.export` permission and always
writes an `AuditLog` entry recording who exported what, and how many rows.

---

## 12. Testing strategy

| Layer | Tool | What it covers |
| --- | --- | --- |
| Unit | Vitest | Permission resolution, scope filtering, every state-machine transition table, assessment/balance arithmetic, schedule-conflict detection, grade rules, PII redaction. No database. |
| Integration | Vitest + real PostgreSQL | API auth, applicant creation, requirement review, enrollment finalization (transaction rollback on failure), payment posting + idempotency, grade submission, audit-log emission. Runs against `ilearned_test`, truncated between tests. |
| E2E | Playwright | The six scenarios in `SPEC.md §36`: freshman, transferee, continuing, medicine, faculty grades, and the negative privacy suite. |

The privacy E2E suite is a first-class requirement, not an afterthought: faculty
reaching an unassigned student, a student reaching another student's route, and
a cashier attempting a grade mutation must each be denied **by the API**, not
merely hidden in the UI.

---

## 13. Local development without Docker

Docker Compose is the documented path. Because Docker Desktop is unavailable on
some institutional Windows workstations, `tools/local-postgres` provides an
equivalent: it downloads a real PostgreSQL binary into a git-ignored directory
and runs it on the same port with the same credentials, so `DATABASE_URL` is
unchanged.

```bash
pnpm db:local
```

This is a developer convenience only. It is never used for deployment.

---

## 14. Deployment design (not part of the MVP)

```
        Internet
           │  HTTPS
    ┌──────▼────────┐
    │ Reverse proxy │  TLS termination, HSTS, rate limiting
    └──────┬────────┘
           │
    ┌──────▼────────────┐      ┌───────────────────┐
    │ Next.js containers │◀───▶│ Managed PostgreSQL │  encrypted, PITR
    │ (2+, stateless)    │      └───────────────────┘
    └──────┬────────────┘
           │            ┌────────────────────────┐
           ├───────────▶│ Private object storage │  no public bucket policy
           │            └────────────────────────┘
           │            ┌────────────────────────┐
           └───────────▶│ Secret manager         │
                        └────────────────────────┘
```

Backups, RPO/RTO targets, monitoring and the pre-production security gate are
documented in `docs/deployment.md` and `docs/security-checklist.md`.

**This prototype is not production-ready.** `SPEC.md §44` lists the institutional
review — DPO sign-off, penetration testing, backup restore drills, MFA rollout —
that must complete before any real student record enters the system.
