From 278293b39713c81d90283d073a15f0bf72a17e65 Mon Sep 17 00:00:00 2001 From: "[dyad]" Date: Sun, 2 Aug 2026 17:26:49 +0200 Subject: [PATCH] Add 21 files, update 16 files, rename 1 file --- .dockerignore | 12 + .env.example | 40 +- .github/workflows/ci.yml | 44 + .gitignore | 1 + Dockerfile | 32 + LICENSE | 2 +- PRD.md | 0 TODO.md | 278 ++++ docker-compose.yml | 29 + docs/ARCHITECTURE.md | 40 + docs/CONTRIBUTING.md | 51 + docs/DATA_MODEL.md | 77 ++ docs/PRD.md | 1116 +++++++++++++++++ docs/README.md | 2 +- docs/ROADMAP.md | 31 + docs/SELF_HOSTING.md | 95 ++ middleware.ts | 14 + package.json | 3 + pnpm-lock.yaml | 47 + .../migration.sql | 46 +- .../migration.sql | 62 +- .../migration.sql | 50 +- .../migration.sql | 15 + prisma/schema.prisma | 16 + prisma/seed-demo.ts | 9 +- prisma/seed.ts | 9 +- scripts/sqlite-backup.mjs | 32 + scripts/sqlite-wal.mjs | 23 + src/app/api/auth/route.ts | 68 + src/app/api/backup/route.ts | 17 +- src/lib/auth.test.ts | 21 + src/lib/auth.ts | 24 +- src/lib/csrf.test.ts | 27 + src/lib/csrf.ts | 41 + src/lib/password-reset.test.ts | 16 + src/lib/password-reset.ts | 21 + src/lib/rate-limit.ts | 3 + src/lib/storage.test.ts | 31 + src/lib/storage.ts | 50 + 39 files changed, 2434 insertions(+), 61 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/ci.yml create mode 100644 Dockerfile delete mode 100644 PRD.md create mode 100644 TODO.md create mode 100644 docker-compose.yml create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/CONTRIBUTING.md create mode 100644 docs/DATA_MODEL.md create mode 100644 docs/PRD.md create mode 100644 docs/ROADMAP.md create mode 100644 docs/SELF_HOSTING.md create mode 100644 middleware.ts create mode 100644 prisma/migrations/20260802173000_add_auth_lifecycle/migration.sql create mode 100644 scripts/sqlite-backup.mjs create mode 100644 scripts/sqlite-wal.mjs create mode 100644 src/lib/auth.test.ts create mode 100644 src/lib/csrf.test.ts create mode 100644 src/lib/csrf.ts create mode 100644 src/lib/password-reset.test.ts create mode 100644 src/lib/password-reset.ts create mode 100644 src/lib/storage.test.ts create mode 100644 src/lib/storage.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2b132fe --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +.github +.next +node_modules +test-results +playwright-report +db +data +.env +.env.local +*.log +docs diff --git a/.env.example b/.env.example index 6a15a84..84f1ba3 100755 --- a/.env.example +++ b/.env.example @@ -1,5 +1,37 @@ -# CompetenceTrack / ExaMetra — Environment Configuration -# Copy this file to .env and adjust values as needed +# SchoolOS environment configuration +# Copy this file to .env. Do not commit a populated .env file. -# Database URL (SQLite via Prisma) -DATABASE_URL=file:./db/custom.db +# SQLite database. Docker Compose overrides this with /data/db/schulos.db. +DATABASE_URL=file:./db/schulos.db + +# Public application URL. Use the externally reachable HTTPS URL in production. +# Multiple comma-separated trusted origins are allowed for approved deployment URLs. +NEXT_PUBLIC_APP_URL=http://localhost:3000 + +# Session and deployment configuration +NODE_ENV=production +SESSION_MAX_AGE_SECONDS=604800 +PASSWORD_RESET_TTL_SECONDS=3600 +# Self-hosted default: new accounts are verified immediately. Set true only when +# an external email verification delivery flow is configured. +AUTH_REQUIRE_EMAIL_VERIFICATION=false +# Development-only: return reset tokens in API responses instead of email. +AUTH_SHOW_RESET_TOKEN=false +SCHULOS_PORT=3000 + +# Local filesystem storage. Docker Compose maps these paths to a persistent volume. +STORAGE_PATH=./data/uploads +EXPORT_PATH=./data/exports +BACKUP_PATH=./data/backups + +# Optional mail delivery for password reset and notifications. +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +SMTP_FROM= + +# Optional AI provider configuration. Leave disabled unless the school enables AI. +AI_ENABLED=false +AI_PROVIDER= +AI_API_KEY= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2c6c608 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + quality: + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + DATABASE_URL: file:./ci.db + NEXT_PUBLIC_APP_URL: http://localhost:3000 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Validate Prisma schema + run: pnpm prisma validate + - name: Generate Prisma client + run: pnpm prisma generate + - name: Apply migrations to disposable database + run: pnpm prisma migrate deploy + - name: Check migration drift + run: pnpm prisma migrate diff --from-migrations prisma/migrations --to-schema-datamodel prisma/schema.prisma --exit-code + - name: Lint + run: pnpm lint + - name: Unit tests + run: pnpm test + - name: Typecheck and production build + run: pnpm typecheck + - name: Audit production dependencies + run: pnpm audit --prod --audit-level=high diff --git a/.gitignore b/.gitignore index e6f3be1..cac8a95 100755 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,7 @@ next-env.d.ts # database db/*.db db/*.db-journal +data/ prisma/*.db prisma/*.db-journal diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a1a11e2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +FROM node:22-alpine AS base +WORKDIR /app + +FROM base AS dependencies +COPY package.json pnpm-lock.yaml* ./ +RUN corepack enable && pnpm install --frozen-lockfile + +FROM base AS builder +COPY --from=dependencies /app/node_modules ./node_modules +COPY . . +RUN corepack enable && pnpm prisma generate && pnpm build + +FROM node:22-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 + +RUN addgroup --system --gid 1001 schulos && adduser --system --uid 1001 schulos + +COPY --from=builder /app/public ./public +COPY --from=builder --chown=schulos:schulos /app/.next/standalone ./ +COPY --from=builder --chown=schulos:schulos /app/.next/static ./.next/static +COPY --from=builder --chown=schulos:schulos /app/prisma ./prisma +COPY --from=builder --chown=schulos:schulos /app/node_modules/.prisma ./node_modules/.prisma + +RUN mkdir -p /data/db /data/uploads /data/exports /data/backups && chown -R schulos:schulos /data + +USER schulos +EXPOSE 3000 + +CMD ["node", "server.js"] diff --git a/LICENSE b/LICENSE index 90d3b8c..6270766 100755 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 Mustafa Cetin +Copyright (c) 2026 Defne Chetin Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/PRD.md b/PRD.md deleted file mode 100644 index e69de29..0000000 diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..a50aa46 --- /dev/null +++ b/TODO.md @@ -0,0 +1,278 @@ +# SchoolOS PRD Execution Plan + +Last reviewed: 2026-08-02 + +## Operating Rules + +- [x] Use SQLite with Prisma for core relational data. +- [x] Keep all server-side access checks deny-by-default and scope teachers to currently assigned classes. +- [x] Keep student data restricted to the linked student record; restrict parent data to `ParentStudentLink` children. +- [x] Prevent Vice Principals from managing administrator accounts. +- [ ] Move every configurable policy, threshold, retention period, warning limit, and escalation window into database-backed school policy records. No new business-rule literals may be introduced. +- [ ] Every new API route must have authorization, school scope, input validation, audit logging where it mutates personal or grading data, and tests. +- [ ] Keep all dates/deadlines in Calendar as the canonical source; other pages can show only read-only summaries sourced from Calendar. +- [ ] Keep notifications in the Notification module as the single delivery pipeline; feature modules emit notifications rather than creating independent alert systems. +- [ ] Do not use emojis in product UI, seed-visible UI, navigation, documentation examples, or new copy. Use Lucide icons already present in the project. +- [ ] Preserve the existing emerald/teal brand palette and semantic colors defined in `src/app/globals.css`. +- [ ] Use one shared motion vocabulary: 160-220ms hover/focus feedback, 220-320ms panel/state transitions, opacity/transform only, and `prefers-reduced-motion` fallbacks. +- [ ] New dashboards use responsive Bento grids. Widgets are read-only views of their owning module and must not duplicate workflows. + +## Definition Of Done + +- [ ] `pnpm lint`, `pnpm test`, `pnpm typecheck`, and E2E smoke tests pass. +- [ ] A feature has server-side authorization tests including cross-school IDOR attempts. +- [ ] A feature has i18n coverage in German and English with no new hardcoded UI strings. +- [ ] Mobile/tablet interaction meets 44x44px minimum target size and keyboard/screen-reader requirements. +- [ ] Documentation, migration, seed behavior, audit logging, and retention implications are updated. + +## Completed Foundation + +- [x] Next.js, TypeScript, Tailwind, shadcn/ui, Prisma, SQLite, Recharts, and DE/EN i18n scaffold exist. +- [x] Core school, year, class, enrollment, student, subject, competency, progress, assessment, grading, reporting, audit, export, and soft-delete models exist. +- [x] Competency templates, entries, assessments, grading schemes, computed grades, competence flower views, report card screens, data export, and GDPR/compliance models exist. +- [x] Student/parent/teacher/admin API scope hardening has been applied across major academic, personal, operational, sensitive-data, and report endpoints. +- [x] Role-safe communication, leave approval, teacher absence, calendar, and escalation baseline workflows exist. +- [x] Compliance models exist: `DataRetentionPolicy`, `DpiaRecord`, `ComplianceConsultation`, `SchoolComplianceStatus`, and `DeletionFlag`. +- [x] Basic signage, exam, notification, dashboard widget, and notebook models/views exist. +- [x] Seed scripts and `.env.example` exist. +- [x] Current checks pass: lint, unit policy tests, build-first typecheck, and production build. + +## Information Architecture And Navigation Consolidation + +### Canonical Top-Level Navigation + +Use these sections only. Do not add a new sidebar entry if a workflow belongs in an existing page tab. + +- [ ] Home + - Dashboard only. Role-specific Bento widgets, quick actions, unread notifications, and read-only upcoming calendar summary. +- [ ] Teach + - Classes, Students, Competencies, Progress, Assessments, Grading, Reports, Attendance, Homework, Lesson Plans, Resources, Digital Notebook. +- [ ] School Life + - Calendar, Communication, Announcements, Events, Library, Transport, Wellness, Counseling, Discipline, Substitutions. +- [ ] Insights + - Competence Flower, Mastery Matrix, Analytics, Curriculum Coverage, Grade Analytics, Data Export. +- [ ] Administration + - Users/Roles, School Setup, Policies, Compliance, Imports, Backups, Signage, Settings. + +### Required Page Ownership And Tab Consolidation + +- [ ] `Classes` owns class setup and roster. Tabs: Overview, Students, Subjects, Staff, Timetable, Seating, Attendance, Competencies. +- [ ] `Student Detail` owns an individual student record. Tabs: Overview, Progress, Assessments, Reports, Attendance, Wellbeing, Goals, Notebook, Portfolio, Support. Do not expose the same student workflows as independent menu pages. +- [ ] `Assessments` owns authoring, competency links, results grid, review, and Exam Mode launch. Tabs: Assessments, Results, Rubrics, Peer/Self Assessment, Exam Sessions. +- [ ] `Reports` owns report drafts, review, finalization, PDF/ZIP exports, and report templates. Tabs: Drafts, Finalized, Templates, Export Queue. +- [ ] `Communication` owns direct threads, class/grade rooms, announcements, escalation queue, and messaging locks. Tabs: Inbox, Groups, Escalations, Announcements, Locks. +- [ ] `Calendar` owns all deadlines, events, exam schedules, absences, timetable reflection, holidays, and availability. Tabs: Calendar, Timetable, Absences, Exam Dates, School Events, Holidays. +- [ ] `Resources` owns Learning Hub content. Tabs: Browse, My Class Resources, Worksheets, Vocabulary, Games, Review Queue. +- [ ] `Administration > Policies` owns permission policies, escalation policies, warning thresholds, quiet hours, module flags, and retention settings. +- [ ] Remove or fold duplicate sidebar entries after each consolidated page is delivered. Update role access lists and command palette at the same time. + +## Phase 18 - Delivery, Security, And Maintainer Baseline + +- [x] Add `Dockerfile` and `docker-compose.yml` with persistent volumes for SQLite, generated exports, uploads, and backups. +- [x] Expand `.env.example` with database path, public base URL, mail/reset configuration, storage path, backup path, feature flags, and optional AI configuration. +- [x] Add `docs/DATA_MODEL.md`, `docs/CONTRIBUTING.md`, and `docs/ROADMAP.md`. +- [x] Add `docs/ARCHITECTURE.md` and `docs/SELF_HOSTING.md`. +- [x] Move the root PRD to `docs/PRD.md` and keep one canonical index in `docs/README.md`. +- [ ] Add GitHub Actions for lint, typecheck, unit tests, Prisma validation, migration drift checks, and Playwright smoke tests. Prisma migration deploy/drift, lint, typecheck, unit, and dependency audit checks are implemented; Playwright smoke tests wait on the test environment. +- [ ] Add dependency audit and secret scanning in CI. +- [x] Replace `bcryptjs` password hashing with Argon2id and document a safe rehash-on-login migration path. Legacy bcrypt hashes verify once and rehash on successful login. +- [ ] Implement password reset tokens, email verification/self-hosted bypass flag, configurable session expiry, optional TOTP setup/recovery codes, and rate-limited auth routes. Reset tokens, self-hosted verification bypass, and session configuration are implemented; TOTP/recovery codes remain. +- [x] Implement central origin-based CSRF protection for cookie-authenticated mutation requests below `/api/`. +- [x] Add real local file storage abstraction for uploads, exports, and backups with path traversal protection. S3-compatible adapter remains a future implementation behind the same interface. +- [x] Add SQLite WAL configuration and verified online backup scripts. Restore remains a deliberate maintenance-environment workflow and requires a smoke test before production cutover. + +Exit criteria: + +- [ ] A non-technical school admin can deploy from clone to login in under 10 documented commands. +- [ ] Authentication tests cover login rate limits, reset flow, session expiry, CSRF rejection, and TOTP when enabled. +- [ ] Backups are real files, restore into a disposable database is verified, and no endpoint returns a fake restore acknowledgment. + +## Phase 19 - Policy And Permission Engine + +- [ ] Create `SchoolPolicy`, `EscalationPolicy`, `ClassMessagingLock`, module activation, quiet-hours, warning-limit, and holiday/calendar policy tables. +- [ ] Replace hardcoded escalation days, weekend calculation, XP constants, leave types, notification thresholds, and warning counts with policy/config queries. +- [ ] Implement a centralized policy service with cached, scoped school configuration and explicit fallback behavior for missing required policy rows. +- [ ] Add an Admin Policy Console with tabs: Access, Communication, Exams, Notifications, Retention, Modules, School Calendar. +- [ ] Add DPO read/gate permissions without granting ordinary user administration. +- [ ] Add policy change audit records with before/after value, actor, reason, and effective time. +- [ ] Use holiday records from Calendar to calculate business days; do not assume Mon-Fri alone. +- [ ] Implement the urgent safety path that bypasses normal communication escalation while preserving audit and notification traceability. + +Exit criteria: + +- [ ] An administrator can change each PRD-configurable rule without deployment. +- [ ] Policy unit tests cover missing policies, precedence, school isolation, holidays, overrides, and emergency bypass. + +## Phase 20 - Navigation, Design System, And Dashboards + +- [ ] Refactor `app-layout.tsx` sidebar and command palette into the canonical five navigation sections. +- [ ] Remove duplicate or overlapping navigation items after moving their workflows to canonical page tabs. +- [ ] Create shared page-shell primitives: `PageHeader`, `PageTabs`, `BentoGrid`, `BentoWidget`, `ModuleEmptyState`, `PermissionEmptyState`, and `LoadingSkeleton`. +- [ ] Create a single icon registry based on Lucide. Remove any emoji-based UI affordances and prohibit adding new ones. +- [ ] Add shared animation tokens/utilities and reduced-motion behavior; remove inconsistent bespoke animations from feature pages during touch work. +- [ ] Rebuild Dashboard as role-aware Bento grid with persisted widget configuration, not duplicated business logic. +- [ ] Dashboard widget catalog: My Classes, Follow-up Students, Upcoming Calendar, Pending Approvals, Notifications, Exam Monitor, Report Deadlines, Compliance Gate, Resource Review Queue. +- [ ] Give every role a narrow dashboard: teacher classroom operations, parent child summary, student personal learning, vice principal operational oversight, admin governance, DPO compliance. +- [ ] Standardize tablet layout, touch targets, focus order, screen-reader labels, density, cards, tabs, tables, dialogs, and empty/error states. + +Exit criteria: + +- [ ] No workflow is reachable from more than one sidebar item. +- [ ] Every duplicate workflow has one canonical owner and optional read-only dashboard summaries only. +- [ ] Desktop, tablet, and mobile visual tests cover every top-level navigation section. + +## Phase 21 - Academics Core Completion + +- [ ] Ensure template assignment clones categories, competencies, and mastery definitions atomically into a school-owned editable copy. +- [ ] Add explicit clone version/history and prevent global-template mutations by school editors. +- [ ] Build roster-first fast entry flow: keyboard-first, tablet-first, under three interactions for the common entry case. +- [ ] Implement append-only progress entries with configurable original-author edit window and immutable audit history. +- [ ] Add configurable competence flower aggregation per template: latest, average of last N, weighted recency. +- [ ] Add flower drilldown to source entries and PNG/SVG export. +- [ ] Complete assessments: delivery mode, competency links, bulk results grid, validation, and class enrollment consistency checks. +- [ ] Move grade computation into a pure tested service with transparent per-rule breakdown. +- [ ] Add `finalizedByUserId`, finalization time, mandatory teacher confirmation, and immutable final-grade audit semantics. +- [ ] Handle grade edge cases: missing inputs, verbal-only scales, partial enrollment, override reason, and school-specific grade scales. +- [ ] Complete report lifecycle: Draft -> Review -> Final -> Versioned, locked final records, revision workflow, data-driven phrase templates. +- [ ] Implement real A4 PDF output, school branding, optional flower/notebook appendix, single download, and ZIP class export. + +Exit criteria: + +- [ ] A teacher can create a class, clone a template, add entries, enter an assessment grid, inspect flower evidence, compute/finalize grades, and export one or many reports. +- [ ] Grading unit tests cover deterministic breakdown and edge cases. + +## Phase 22 - Communication And Notification Completion + +- [ ] Replace `CommunicationRoom`-only escalation behavior with policy-backed `EscalationPolicy` and `EscalationRequest` records. +- [ ] Implement class, grade-level, and custom moderated social rooms with real membership and content moderation states. +- [ ] Implement class-hours messaging lock using Calendar/Timetable period boundaries; urgent messages still deliver. +- [ ] Add message read receipts, attachments through the storage abstraction, moderation queue, and retention references. +- [ ] Add counselor role/department routing for eligible student escalations. +- [ ] Provide an administrator case override with reason/audit trail. +- [ ] Centralize all notifications into one delivery pipeline: in-app center, browser push, email fallback where configured, quiet hours, emergency bypass. +- [ ] Remove duplicate feature-specific notification UI; retain only contextual status banners plus the central notification center. +- [ ] Add notification preference and delivery failure/retry observability. + +Exit criteria: + +- [ ] Parent/student escalation uses a database policy and a holiday-aware deadline. +- [ ] Teachers message management directly; emergency contact is always available without escalation. +- [ ] One notification pipeline delivers all module events. + +## Phase 23 - Calendar, Leave, Attendance, And School Operations + +- [ ] Make Calendar the canonical event store for absences, exams, report deadlines, timetable, holidays, school events, and signage date windows. +- [ ] Add explicit event audience/visibility relation for student, parent, class, staff, and administrator-private events. +- [ ] Finish student leave workflow with evidence files, parent verification, teacher workflow queue, final admin approval, calendar publication, and auditable rejection reasons. +- [ ] Finish teacher leave workflow with vice-principal notification, private admin notes, class coverage link, and calendar audience controls. +- [ ] Add school holiday management and use it in escalation, attendance, reporting, and timetable computations. +- [ ] Consolidate attendance, illness, leave, and timetable visibility into Calendar tabs; keep data entry routes canonical. +- [ ] Complete substitutions: absence -> available substitute search -> assignment -> timetable/calendar reflection -> notifications. +- [ ] Complete transport and library operational flows with real permissions, return/overdue state, and audit records. + +Exit criteria: + +- [ ] No page stores a second independent date/deadline implementation. +- [ ] Every absence and approved leave has one linked calendar record with audience-aware visibility. + +## Phase 24 - Learning Hub, AI Tutor, And Content Governance + +- [ ] Replace generic `Resource` semantics with PRD-aligned resource types, review status, competency links, ownership, and school/global library behavior. +- [ ] Add worksheet solution access rules and PDF worksheet export with teacher-controlled solution inclusion. +- [ ] Add vocabulary sets/items with spaced repetition, student progress, and teacher assignment surfaces. +- [ ] Add game progress scoped by student, subject, and school; exclude addictive/profiling mechanics. +- [ ] Add resource search/filter by subject, grade, competency, and review status; surface weak-competency recommendations from the flower. +- [ ] Add AI content authoring queue: every AI draft is `PENDING_REVIEW`, never directly published. +- [ ] Add `AiTutorInteraction` with pseudonymous/summary-only default logging, retention link, consent/config setting, and audit access. +- [ ] Build server-side AI Tutor safety gate: deny when active digital exam session exists; enforce homework/assessment hint-only policy with structured request context. +- [ ] Add translation requests labeled AI-translated, review reporting, and no persistent language/ethnicity profiling. +- [ ] Keep AI provider/storage optional and local-first; do not require third-party paid services for core operation. + +Exit criteria: + +- [ ] AI Tutor cannot answer during an active locked exam even with a direct API request. +- [ ] AI-generated content cannot become visible before human review. + +## Phase 25 - Exam Mode, PWA, And Digital Notebook + +- [ ] Add `ExamIncidentEvent` model and complete exam lifecycle states, device IDs, reasons, teacher actions, and parent/admin visibility rules. +- [ ] Implement server-side active-exam middleware that blocks AI Tutor, Learning Hub assistance, and ordinary messaging APIs for that student. +- [ ] Build live teacher exam monitor with pause, resume, cancel, bathroom approval, distress handling, warning issuance, and incident timeline. +- [ ] Make third warning auto-pause only; require a human for any disciplinary record creation. +- [ ] Implement fullscreen/kiosk UX with accessibility fallback and documented browser limitations. +- [ ] Add Workbox service worker for offline shell, push delivery, navigation handling, and installable PWA behavior. +- [ ] Add notebook templates, typed blocks, structured stylus stroke data, non-destructive teacher annotations, page history, subject/year organization, and PDF export. +- [ ] Use worker offloading for costly stroke/image operations only after profiling; keep a no-worker fallback. + +Exit criteria: + +- [ ] Exam API integrity remains enforced if the client bypasses fullscreen. +- [ ] Notebook annotations never overwrite original student work. + +## Phase 26 - Signage, Emergency, And Reliability + +- [ ] Align signage schema with display, slide, playlist, approval, priority, date-window, and rotation requirements. +- [ ] Add teacher slide request queue; only policy-authorized content may publish. +- [ ] Implement emergency alerts as highest-priority display override with explicit resolve action and audit record. +- [ ] Send emergency notifications to configured staff/student/parent audiences with retry/failure telemetry. +- [ ] Implement staff-only counseling notice with no student identifying detail on public displays. +- [ ] Add signage display endpoint suitable for TV devices and test offline/reconnect behavior. + +Exit criteria: + +- [ ] Emergency alert override is independently tested for display, notification delivery, resolution, and audit logging. + +## Phase 27 - Compliance, Rights, And Retention Completion + +- [ ] Document purpose and legal basis for every personal-data entity in `docs/DATA_MODEL.md`. +- [ ] Add entity-to-retention-category mapping and scheduled retention sweep using `DataRetentionPolicy` rows. +- [ ] Ensure legally mandated retention values have no guessed defaults; require DPO/school confirmation before use. +- [ ] Complete unified staff/student/parent self-service export including messages, notebooks, progress, reports, permitted disciplinary records, and generated files. +- [ ] Complete partial erasure with retained-record explanation, file deletion, audit trail, and DPO approval where required. +- [ ] Generate RoPA from module/entity metadata and keep `docs/legal/verzeichnis-verarbeitungstaetigkeiten.md` reproducible. +- [ ] Make privacy notice generator derive configured retention and module states; include non-legal-advice disclaimer. +- [ ] Gate high-risk AI Tutor and Exam Mode activation on DPO/DPIA records and onboarding consultations. +- [ ] Add Personalrat module checklist/spec sheets describing monitoring, viewers, retention, and prohibited performance-evaluation use. +- [ ] Add module-level consultation status for DPO, Schultrager, Elternbeirat/Schulkonferenz, and Personalrat. + +Exit criteria: + +- [ ] High-risk modules cannot activate without the configured compliance gate. +- [ ] Export/erasure and retention workflows have end-to-end tests. + +## Phase 28 - Testing, Accessibility, And Release Readiness + +- [ ] Add unit tests for policy engine, authorization, business-day calculation with holidays, grading engine, report phrase generation, retention sweep, and exam gates. +- [ ] Add API IDOR matrix tests for each protected school/student/class resource and all roles. +- [ ] Add Playwright E2E: register/login, class setup, template clone, student import, progress entry, assessment grid, grade finalization, report PDF, parent portal, escalation, leave approval, exam gate. +- [ ] Add visual regression coverage for each canonical navigation page at desktop, tablet, and mobile breakpoints. +- [ ] Audit WCAG 2.1 AA: keyboard navigation, focus order, labels, dialogs, contrast, motion reduction, touch targets, table alternatives, and screen-reader announcements. +- [ ] Add performance budgets for roster, dashboard, flower, report generation, and tablet entry flows. +- [ ] Add production readiness review: migrations, seed behavior, backup restore drill, Docker deployment drill, no mock data in shipped UI, and license decision. +- [ ] Add `CHANGELOG.md` in `docs/` and release/version workflow. + +Exit criteria: + +- [ ] CI runs lint, typecheck, Prisma validation, unit tests, security checks, and Playwright smoke tests on every pull request. +- [ ] A clean environment can seed, deploy, back up, restore, and execute the critical teacher/parent/student paths. + +## Suggested Execution Order + +1. Phase 18: delivery/security baseline. +2. Phase 19: policy engine. +3. Phase 20: navigation/design system/dashboard consolidation. +4. Phase 21: academics completion. +5. Phase 22 and Phase 23 in parallel after policy/calendar primitives exist. +6. Phase 24 after policy, notification, and retention primitives exist. +7. Phase 25 after AI exam gate and PWA foundations are ready. +8. Phase 26 after notification reliability is proven. +9. Phase 27 throughout, with final compliance gates before release. +10. Phase 28 continuously, with final release gate after all feature phases. + +## Current Blockers + +- [ ] Confirm AGPLv3 versus MIT license direction. +- [ ] Confirm whether AI features remain optional and which local/provider integration is permitted. +- [ ] Confirm school-specific legal retention values with the DPO; do not infer them. +- [ ] Confirm emergency alert audience and after-hours teacher notification policy with school leadership. +- [ ] Confirm target browser/device policy for kiosk/fullscreen limitations. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4968eab --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,29 @@ +services: + schulos: + build: + context: . + dockerfile: Dockerfile + container_name: schulos + restart: unless-stopped + environment: + DATABASE_URL: ${DATABASE_URL:-file:/data/db/schulos.db} + NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3000} + SESSION_MAX_AGE_SECONDS: ${SESSION_MAX_AGE_SECONDS:-604800} + AI_ENABLED: ${AI_ENABLED:-false} + AI_PROVIDER: ${AI_PROVIDER:-} + AI_API_KEY: ${AI_API_KEY:-} + SMTP_HOST: ${SMTP_HOST:-} + SMTP_PORT: ${SMTP_PORT:-587} + SMTP_USER: ${SMTP_USER:-} + SMTP_PASSWORD: ${SMTP_PASSWORD:-} + SMTP_FROM: ${SMTP_FROM:-} + STORAGE_PATH: ${STORAGE_PATH:-/data/uploads} + EXPORT_PATH: ${EXPORT_PATH:-/data/exports} + BACKUP_PATH: ${BACKUP_PATH:-/data/backups} + ports: + - "${SCHULOS_PORT:-3000}:3000" + volumes: + - schulos-data:/data + +volumes: + schulos-data: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..d6d7332 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,40 @@ +# Architecture + +SchoolOS is a single Next.js application with TypeScript, Prisma, and SQLite. +It is intentionally deployable as one self-hosted container for a single +school. Core personal data remains in SQLite; generated uploads, exports, and +backups live in persistent local storage. + +## Components + +- Next.js App Router hosts the UI and API routes. +- Prisma owns schema validation, migrations, and database access. +- SQLite is the default relational store. Use WAL mode in the deployment + bootstrap and retain the database plus `-wal` and `-shm` files during backups. +- The storage layer uses local filesystem paths first. Future S3-compatible + storage must use the same storage interface rather than feature-specific SDKs. +- `src/lib/storage.ts` is the single local filesystem boundary. It constrains + uploads, exports, and backups to configured roots and rejects path traversal. +- Notification, Calendar, Policy, and Authorization remain shared services; + feature modules must not create duplicate delivery, date, or permission logic. + +## Deployment Boundary + +`docker-compose.yml` runs the application with a named `/data` volume: + +- `/data/db`: SQLite database files +- `/data/uploads`: uploaded evidence and documents +- `/data/exports`: generated reports and rights exports +- `/data/backups`: verified backups + +Use a reverse proxy that terminates TLS in production. Configure +`NEXT_PUBLIC_APP_URL` with the public HTTPS URL. + +## Security Notes + +- Server-side authorization is mandatory; hiding navigation does not grant + access control. +- Secrets belong only in `.env` or deployment secret management. +- AI is optional and must remain disabled until policy/DPIA gates allow it. +- This engineering documentation is not legal advice. A school must involve its + DPO, Schultrager, and Personalrat before production use. diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md new file mode 100644 index 0000000..fcd4348 --- /dev/null +++ b/docs/CONTRIBUTING.md @@ -0,0 +1,51 @@ +# Contributing + +## Setup + +1. Use Node.js 22 and pnpm. +2. Copy `.env.example` to `.env` and set `DATABASE_URL`. +3. Install dependencies with `pnpm install`. +4. Generate Prisma client with `pnpm db:generate`. +5. Create local schema with `pnpm db:push` or run the reviewed migration flow. +6. Seed only non-production data with `pnpm db:seed`. +7. Start the app with `pnpm dev`. + +## Required Checks + +Run these before opening a pull request: + +```bash +pnpm prisma validate +pnpm lint +pnpm test +pnpm typecheck +git diff --check +``` + +## Engineering Rules + +- Use server-side authorization for every route. UI visibility is never a + substitute for access control. +- Scope every school-owned query to the caller school. Scope teacher data to + assigned classes, student data to self, and parent data to linked children. +- Use `src/lib/access-policy.ts` and `src/lib/role-access.ts` instead of + duplicating access rules. +- Use `src/lib/storage.ts` for local files. Never assemble arbitrary paths from + request input. +- Use the Calendar and Notification modules as shared sources, not local copies + of deadlines or delivery logic. +- Keep new UI icon-based with Lucide. Do not introduce emoji UI affordances. +- Preserve existing palette, shared motion vocabulary, responsive behavior, and + role-aware page ownership defined in `TODO.md`. +- Add a migration for every Prisma schema change and update relevant docs/tests. +- Do not add hardcoded policy values when a school-configurable policy is + required. + +## Pull Requests + +- Keep a pull request narrowly scoped to one TODO phase item. +- Explain security, migration, audit, export/erasure, and retention effects. +- Include tests for authorization failures and cross-school IDOR attempts when + a route handles school or student data. +- Do not commit `.env`, database files, backup files, uploaded documents, or + generated test artifacts. diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md new file mode 100644 index 0000000..ac1c704 --- /dev/null +++ b/docs/DATA_MODEL.md @@ -0,0 +1,77 @@ +# Data Model + +SchoolOS uses Prisma with SQLite. IDs are portable strings and timestamps are +stored in UTC. The schema is the source of truth; this document is a domain +map, not a replacement for `prisma/schema.prisma`. + +## Organization And Identity + +- `School`, `SchoolYear`, `SchoolDistrict`: organizational boundaries. +- `User`: authenticated account, role, school membership, locale, and soft + deletion state. +- `ClassGroup`, `ClassGroupTeacher`, `Enrollment`: teaching assignments and + current/historical class membership. +- `Student`, `ParentStudentLink`, `ParentContact`, `EmergencyContact`: student + profile and linked guardians. Access must always be evaluated through the + active student, parent link, class assignment, and school scope. + +## Academic Record + +- `Subject`, `CompetencyTemplate`, `CompetencyCategory`, `Competency`, and + `MasteryLevelDefinition` define editable school competency frameworks. +- `ClassCompetencyAssignment` links a framework to a class. +- `LearningProgressEntry` is the evidence record used by progress, mastery, + and competence visualizations. +- `Assessment`, `AssessmentCompetencyLink`, and `AssessmentResult` capture + assessment creation, competency links, and student results. +- `GradingScheme`, `GradingWeightRule`, and `ComputedGrade` hold calculation + rules and computed outcomes. +- `Report`, `ReportCardTemplate`, and `ReportSection` hold report-card drafts, + templates, and sections. + +## Classroom Operations + +- `AttendanceSession` and `AttendanceRecord`: attendance and absence status. +- `LessonPlan`, `Homework`, and `HomeworkSubmission`: instructional planning + and student work. +- `TimetableSlot`, `CalendarEvent`, `SchoolEvent`, and `EventRegistration`: + time-bound school activity. +- `TeacherAbsence`, substitute models, transport models, and library checkout + models support daily school operations. + +## Student Support And Communication + +- `TeacherNote`, `BehaviorCategory`, `BehaviorIncident`, and + `BehaviorIntervention`: staff-only support context. +- `IllnessReport`, `CounselingAppointment`, `HealthRecord`, and `Wellness` + models contain sensitive information; they require strict role, student, and + school checks. +- `CommunicationRoom`, `CommunicationRoomMember`, `CommunicationMessage`, + `ParentMessage`, `Announcement`, and `Notification` provide communication + and delivery records. + +## Student Work And Learning Hub + +- `Notebook`, `NotebookPage`, `NotebookPageVersion`, and `Drawing` store + digital student work and version history. +- `Resource`, `SubjectCategory`, `SubjectContent`, `SubjectTopic`, + `SubjectLesson`, and `LessonQuestion` represent learning content. +- Badge, reward, competition, portfolio, goal, and study-plan models support + student-facing learning workflows. + +## Compliance And Audit + +- `AuditLog` records sensitive operational actions. +- `DataExportRequest`, `DeletionFlag`, `DataRetentionPolicy`, `DpiaRecord`, + `ComplianceConsultation`, and `SchoolComplianceStatus` support rights, + retention, and governance workflows. +- `Backup` stores metadata for storage-backed backup files; backup files live + below `BACKUP_PATH` and must not be placed in the database. + +## Change Rules + +- Additive schema changes require a Prisma migration and an updated seed path. +- Never rename or remove a stored field without a migration/backfill plan. +- New personal-data fields require a purpose, access policy, audit requirement, + export behavior, erasure behavior, and retention category before release. +- New feature tables must include school scope where the data is school-owned. diff --git a/docs/PRD.md b/docs/PRD.md new file mode 100644 index 0000000..d4aeaf0 --- /dev/null +++ b/docs/PRD.md @@ -0,0 +1,1116 @@ +# BUILD PROMPT: "CompetenceTrack" — A Free, Open-Source School Competency & Grading Platform (Digidoo Clone) + +## 0. Context & Mission + +You are building a full-stack web application called **CompetenceTrack** (working name — feel free to suggest alternatives). It is a **free, open-source, donation-funded school project**, inspired by the commercial product **Digidoo** (digidoo.com). The goal is to give teachers a digital tool to: + +1. Track student **competencies** ("Kompetenzraster" / competency grids) instead of paper checklists. +2. Link individual **assessments/performance checks** to specific competencies. +3. Visualize each student's progress with a **radar/flower chart ("Competence Flower")**. +4. Auto-generate **report cards / progress reports** with one click. +5. Optionally calculate **grades** from weighted competencies and assessments. + +This project is **not for profit** — it will be given away for free to schools, and any support the project receives will go toward the school. Because of this: +- No paid tiers, no license checks, no telemetry/tracking, no vendor lock-in. +- Prioritize **simplicity, low hosting cost, and easy self-hosting** (a school or a single teacher should be able to run this on a small VPS or even a Raspberry Pi). +- Database must be **SQLite** (see §4) — no external DB server required. +- Code must be clean, well-documented, and easy for future student/volunteer contributors to maintain. + +--- + +## 1. Reference Product Analysis (what we are learning from, NOT copying verbatim) + +Digidoo (https://www.digidoo.com/de/) is a German/Austrian EdTech SaaS. Do **not** copy their branding, illustrations, exact wording, logo, or any copyrighted text/images — this must be an original UI/UX and original copywriting, only inspired by the **feature set and workflow concept**. Core concepts observed on their site that we want to reproduce functionally: + +- **Competency grids ("Kompetenzraster")**: Pre-built and customizable sets of subject/grade-level competencies (e.g., per state curriculum, per subject, per grade level) that teachers can use as templates or fully customize. +- **Learning progress entries**: Teachers log individual observations/entries per student, linking them to one or more competencies, over time (a running log, not a single snapshot). +- **Performance assessments ("Leistungsfeststellung")**: Structured tests/assignments/checks that are linked to competencies, feeding into the same student timeline. +- **Competence Flower ("Kompetenzblume")**: A radar/spider chart per student showing strength per competency category — "the bigger the petal, the stronger the performance" — used for parent-teacher conferences and progress meetings. +- **One-click reports**: Automatically generated, competency-based report text (not generic phrases) and full report-card documents (PDF) — no copy-pasting or manual formatting. +- **Grade management ("Notenverwaltung")**: Configurable weighting between learning-progress entries and formal assessments to compute a transparent final grade — the teacher decides the weighting formula, not a black box. +- **Role/permission system**: Teachers, school administrators, groups/classes, subject hand-offs when a student changes teacher or school. +- **Multi-school-type support**: Elementary school, middle school (Sekundarstufe I), high school/Gymnasium (Sekundarstufe II) — different competency catalogs and grading rules per school type/grade band. +- **Data portability**: Users can export all their data (competencies, entries, reports) at any time in common formats (CSV/JSON/PDF). +- **Free tier that "just works"**: A permanently free "Basis" plan with core functionality — since our whole project is free, we implement this as simply "the whole app, free, no tiers." + +We are explicitly **excluding** from scope (not needed since this is free/single-org, not a multi-tenant SaaS business): +- Billing/subscriptions/Stripe integration. +- Marketing site, SEO landing pages, blog/CMS ("Wissenswertes" articles), press page. +- SSO/enterprise integrations (WebUntis, etc.) — keep as a "future idea," not MVP. + +--- + +## 2. High-Level Goals & Non-Goals + +### Goals +- A teacher can register/log in, create classes, add students, and assign a competency grid (template or custom) to a class/subject. +- A teacher can log learning-progress entries and assessments per student, linked to competencies. +- The system auto-calculates a **competency mastery level** per student per competency (based on the entries logged, using a configurable scale). +- The system shows a **radar chart ("Competence Flower")** per student. +- The system can compute a **weighted grade** per subject per student, based on teacher-defined weighting rules. +- The system can generate a **PDF report** per student (competency summary + optional grade) with one click. +- Data model supports **elementary and secondary school** competency structures (configurable grade bands, subjects, and competency hierarchies). +- Everything runs self-hosted with **SQLite** — zero external DB dependency. +- Fully responsive UI, usable on tablets (teachers observed using tablets in classrooms). +- GDPR-friendly by design (data minimization, full export, full delete) even though this isn't a commercial SaaS — schools still handle student data and should be able to trust the tool. + +### Non-Goals (v1) +- No payments, no multi-tenant SaaS billing. +- No native mobile apps (a responsive PWA is enough). +- No real-time collaborative editing. +- No AI-generated report text in v1 (flag as a "Phase 2 / stretch goal" — see §11). + +--- + +## 3. Tech Stack (proposed — adjust if you have a strong reason, but justify the change) + +- **Frontend**: React (or Next.js in a single full-stack app) + TypeScript. UI library: Tailwind CSS + shadcn/ui for accessible, clean components. Charting: Recharts or Chart.js (radar chart for the "Competence Flower"). +- **Backend**: Node.js (within Next.js API routes / App Router server actions) **or** a separate lightweight Express/Fastify API — pick ONE cohesive full-stack framework to keep this simple for a small volunteer team. Next.js (App Router) is recommended so frontend + backend live in one deployable app. +- **Database**: **SQLite**, accessed through **Prisma ORM** (or Drizzle ORM as an alternative — pick one and justify). Reasoning: SQLite keeps hosting trivial (single file, easy backups, works great for a single school's scale of data — thousands of students, not millions), and Prisma/Drizzle gives us migrations, type safety, and an easy upgrade path to Postgres later if a school ever outgrows SQLite (design the schema to be Postgres-compatible too, avoiding SQLite-only quirks). +- **Auth**: Self-hosted email/password auth with hashed passwords (bcrypt/argon2) + session cookies (or a lightweight library like Lucia Auth / Auth.js with a credentials provider). No third-party paid auth services. Support optional TOTP-based 2FA (stretch goal, since Digidoo offers 2FA). +- **PDF generation**: A server-side PDF library (e.g., `@react-pdf/renderer` or Puppeteer-based HTML-to-PDF) for report cards. +- **File storage**: Local filesystem storage for generated PDFs/exports (school-hosted, no S3 dependency required, but abstract storage behind an interface so S3-compatible storage can be swapped in later). +- **Hosting target**: Single Docker container (or docker-compose with a volume for the SQLite file + generated files), deployable on a small VPS. Include a `docker-compose.yml`. +- **i18n**: German and English UI strings from day one (the reference product targets DACH schools), using a simple i18n library (e.g., `next-intl`). Structure all UI text through translation keys — do not hardcode strings. +- **Testing**: Vitest/Jest for unit tests, Playwright for a handful of critical E2E flows (login, create competency grid, log an entry, generate report). + +Deliver a `docs/ARCHITECTURE.md` explaining the chosen stack and why, plus a `docs/SELF_HOSTING.md` explaining how a non-technical school IT admin can deploy this with Docker. + +--- + +## 4. Database Design (SQLite via Prisma/Drizzle) + +Design a normalized relational schema. Suggested entities (adjust field names to your ORM conventions; this is the conceptual model, not literal DDL): + +### Core organizational entities +- **School** — id, name, schoolType (enum: elementary, middle, gymnasium, other), country, timezone, createdAt. +- **User** — id, schoolId (nullable for solo teachers not tied to a school), email, passwordHash, firstName, lastName, role (enum: TEACHER, SCHOOL_ADMIN, SUPER_ADMIN), locale, twoFactorSecret (nullable), createdAt, deletedAt (soft delete). +- **SchoolYear** — id, schoolId, label (e.g. "2026/2027"), startDate, endDate. +- **ClassGroup** ("Klasse/Gruppe") — id, schoolId, schoolYearId, name, gradeLevel (int), schoolType. +- **ClassGroupTeacher** (many-to-many) — classGroupId, userId, role (e.g., HOMEROOM_TEACHER, SUBJECT_TEACHER). +- **Student** — id, schoolId, firstName, lastName, dateOfBirth (nullable, minimize data collected), externalId (nullable, for import matching), createdAt, deletedAt. +- **Enrollment** — id, studentId, classGroupId, schoolYearId, startDate, endDate (nullable = still enrolled). This models students moving between classes/schools over time. + +### Subject & competency structure +- **Subject** — id, schoolId (nullable = global/template), name, gradeLevelMin, gradeLevelMax. +- **CompetencyTemplate** — id, name, description, subjectId (nullable if cross-subject), schoolType, gradeLevelMin, gradeLevelMax, isGlobalTemplate (bool), createdByUserId (nullable for system-provided templates), version. +- **CompetencyCategory** — id, competencyTemplateId, name, order (for grouping competencies into "petals" of the flower — e.g., "Reading," "Writing," "Listening" within Language Arts). +- **Competency** — id, categoryId, code (short reference like "D.3.LES.01"), title, description, order, masteryLevels (JSON or related table — see below). +- **MasteryLevelDefinition** — id, competencyId (or categoryId, if scale is shared), levelValue (e.g., 1–4), label (e.g., "not yet demonstrated," "developing," "proficient," "advanced"), description. +- **ClassCompetencyAssignment** — links a ClassGroup + Subject to a chosen CompetencyTemplate for a given SchoolYear, plus any per-school customizations (could be a cloned/forked copy of the template rather than a live reference, so a school's edits don't affect the global template — design a "clone on customize" strategy). + +### Progress tracking & assessment +- **LearningProgressEntry** — id, studentId, competencyId, teacherId, classGroupId, date, masteryLevelValue, note (free text, optional), createdAt. This is the append-only observation log. +- **Assessment** ("Leistungsfeststellung") — id, classGroupId, subjectId, teacherId, title, date, type (enum: TEST, ORAL, PROJECT, HOMEWORK, OTHER), maxScore (nullable), weight (default weight for grading). +- **AssessmentCompetencyLink** (many-to-many) — assessmentId, competencyId, weight (how much this assessment contributes to that competency's mastery). +- **AssessmentResult** — id, assessmentId, studentId, score (nullable), masteryLevelValue (nullable), note. + +### Grading +- **GradingScheme** — id, classGroupId or subjectId, name, type (enum: NUMERIC_GRADE, VERBAL_FEEDBACK, COMBINED), scaleDefinition (JSON — e.g., grade 1–6, or 1–15 points, configurable per country/state). +- **GradingWeightRule** — id, gradingSchemeId, sourceType (enum: LEARNING_PROGRESS, ASSESSMENT), targetRef (competencyId or assessmentId or categoryId), weightPercent. Defines "how much of the final grade comes from what." +- **ComputedGrade** — id, studentId, subjectId, classGroupId, schoolYearId, period (e.g., "Semester 1"), computedValue, computedAt, isFinalized (bool), overriddenValue (nullable, teacher can manually override with a reason), overrideReason. + +### Reporting +- **Report** — id, studentId, classGroupId, schoolYearId, period, generatedByUserId, generatedAt, status (enum: DRAFT, FINAL), pdfFilePath, includesGrades (bool). +- **ReportSection** — id, reportId, competencyCategoryId (nullable), generatedText, order — stores the generated narrative per section so reports remain auditable/editable before finalizing. + +### Auditing & compliance +- **AuditLog** — id, userId, action, entityType, entityId, timestamp, metadata (JSON) — for accountability (who edited a grade, who exported data). +- **DataExportRequest** — id, requestedByUserId, scope (student/class/school), status, filePath, requestedAt, completedAt — supports the "you can always export your data" principle. + +**Design requirements:** +- Use UUIDs (or ULIDs) as primary keys for portability, not SQLite autoincrement-only ints, to make future multi-instance sync/export easier. +- All timestamps in UTC. +- Soft deletes on Student/User (never hard-delete a student's historical record silently — provide an explicit "permanently erase" GDPR-style flow instead that cascades properly and logs an AuditLog entry). +- Write Prisma/Drizzle schema files, plus at least one seed script (`prisma/seed.ts`) that creates: a demo school, a demo teacher login, 2 demo classes, ~15 demo students, a sample competency template (Math + German, elementary level, 2 categories, ~6 competencies each with a 4-point mastery scale), a handful of learning-progress entries and one assessment, so the app is explorable immediately after `docker-compose up`. +- Provide migration scripts and document the "SQLite → Postgres" upgrade path in `docs/ARCHITECTURE.md` (which ORM features to avoid to keep this portable — e.g., avoid raw SQLite-only pragmas in app logic, avoid case-sensitive collation assumptions, use ORM-level constraints not SQLite triggers). +- Enable SQLite WAL mode and document backup strategy (just copy the `.sqlite` file + files directory; note SQLite's concurrent-write limitations and why that's fine at single-school scale, with guidance on when a school should be advised to migrate to Postgres). + +--- + +## 5. Core User Roles & Permissions + +- **Teacher**: Manage their own classes, students in their classes, log entries/assessments, view competence flowers, generate reports, customize competency grids for their classes. +- **School Admin**: Everything a teacher can do, plus manage the school's user list, manage school-wide competency templates, manage school years/classes, view audit logs, run data exports/erasure. +- **Super Admin** (project maintainer / self-hoster's own account): Manage global competency template library (the "starter" templates shipped with the app, akin to Digidoo's pre-built state curricula), manage schools if the instance hosts more than one school, system settings. + +Implement middleware-level authorization checks (not just UI hiding) on every API route — a Teacher must never be able to query another school's data even by guessing IDs. Write tests specifically for this (IDOR-style access control tests). + +--- + +## 6. Key Feature Specs (User Stories + Acceptance Criteria) + +### 6.1 Onboarding & Auth +- As a new user, I can register with email/password, verify my email (or skip verification in self-hosted mode via config flag), and log in. +- As a user, I can reset my password via email link. +- As a user, I can optionally enable TOTP 2FA. +- Acceptance: passwords hashed with argon2id, rate-limited login attempts, session expiry configurable, CSRF protection on all mutating routes. + +### 6.2 School / Class / Student setup +- As a teacher, I can create a class group, set its grade level and school type, and add students (manually one-by-one, or via CSV import with column mapping). +- As a teacher, I can assign one or more subjects to a class. +- Acceptance: CSV import validates rows, shows a preview + error list before committing, supports re-running with corrections. + +### 6.3 Competency grid management +- As a teacher/admin, I can browse a library of starter competency templates (seeded ones + any the school has created), filtered by school type, grade level, and subject. +- As a teacher, I can assign a template to a class+subject for the school year, which creates an editable **school-owned copy** (so edits don't mutate the shared template). +- As a teacher/admin, I can edit competency categories/items/mastery-level definitions on my school's copy, or build a fully custom grid from scratch. +- Acceptance: template cloning is atomic (categories + competencies + mastery levels all copied together); editing a school copy never changes the original global template. + +### 6.4 Logging learning progress +- As a teacher, from a class roster view, I can quickly log an entry for a student: pick competency (or multiple), pick mastery level, add optional note, save — optimized for fast repeated entry during/after class (keyboard-friendly, minimal clicks, works well on tablet). +- As a teacher, I can see a student's full timeline of entries. +- Acceptance: entry creation is <3 interactions from the roster view for the common case; entries are immutable-by-default but editable by the original author within a configurable time window, with all edits captured in AuditLog. + +### 6.5 Assessments +- As a teacher, I can create an assessment (test/oral/project/etc.), link it to one or more competencies with weights, and record per-student results (score and/or mastery level). +- Acceptance: bulk entry UI (spreadsheet-like grid: students × one assessment) for fast grading of a whole class at once. + +### 6.6 Competence Flower (radar chart) +- As a teacher/parent-in-meeting, I can view a per-student radar chart where each axis is a competency category and the value is the computed current mastery level (most recent entries weighted, or average — make the aggregation method configurable per template: "latest value," "average of last N," "weighted by recency"). +- Acceptance: chart updates live as new entries are logged; hovering/tapping a petal shows the underlying entries; exportable as an image (PNG/SVG) for use in printed materials or slides. + +### 6.7 Grade computation +- As a teacher, I can define a grading scheme per subject/class (numeric grade scale, or verbal feedback, or combined) and a weighting rule set (e.g., "60% assessments, 40% learning-progress mastery average"). +- As a teacher, I can view the computed grade per student with a transparent breakdown ("this grade = X% from Assessment A, Y% from competency category B, ..."). +- As a teacher, I can manually override a computed grade with a required justification note (stored in AuditLog + ComputedGrade.overrideReason). +- Acceptance: computation logic is a pure, well-tested function (given inputs, deterministic output) — cover with unit tests including edge cases (missing data points, all-verbal no numeric grade, partial-year enrollment). + +### 6.8 Reports +- As a teacher, I can generate a report for a student (or a whole class in bulk) for a given period, combining: a competency summary (with the flower chart embedded), narrative text generated from templated phrases per mastery level per competency (NOT AI-generated in v1 — use a configurable phrase-template system: e.g., "shows [level.label] performance in [competency.title]"), and optionally the computed grade. +- As a teacher, I can review/edit the generated draft text before finalizing (report status DRAFT → FINAL; FINAL reports are locked and versioned). +- As a teacher, I can export a report as PDF and download it, or bulk-export a whole class as a ZIP of PDFs. +- Acceptance: PDF is well-formatted (printable A4, school-branding-agnostic — configurable logo/header per school), generation completes in a reasonable time even for a class of 30, and the phrase-template system is data-driven (stored in DB / config, not hardcoded strings) so schools can localize/adjust wording. + +### 6.9 Data portability & privacy +- As a school admin, I can export all of a class's or school's data as structured JSON/CSV at any time. +- As a school admin, I can permanently erase a student's data (with confirmation + audit trail) when required (e.g., student leaves the school and retention period expires). +- Acceptance: exports include everything needed to reconstruct history in another system; erasure cascades correctly across all tables including generated PDFs on disk. + +### 6.10 Dashboard +- As a teacher, my home dashboard shows: my classes, recent activity (entries logged this week), students who haven't had an entry logged in X days (flag for follow-up), upcoming report deadlines (configurable per school year). + +--- + +## 7. UI/UX Requirements + +- Original visual design — do not reuse Digidoo's illustrations, exact color palette, or copy. Build a clean, warm, education-appropriate design system (define a small design token set: colors, spacing, typography) — consider Tailwind + shadcn/ui defaults customized with an accent color distinct from Digidoo's. +- Mobile/tablet-first for the "log an entry" flow specifically (teachers use this standing in a classroom); desktop-first is fine for admin/setup screens and reports. +- Full keyboard navigation and screen-reader labels (WCAG 2.1 AA target) — schools may have accessibility obligations. +- Support German and English from day one via the i18n system (§3); default to German since the target audience is DACH schools, but make English complete too since this is described as an open-source/free project others may adopt. +- Empty states and onboarding checklists for a brand-new school ("1. Create your first class → 2. Assign a competency grid → 3. Add students → 4. Log your first entry"). + +--- + +## 8. Non-Functional Requirements + +- **Self-hostability**: one `docker-compose.yml`, one `.env.example`, a `README.md` with copy-pasteable setup instructions targeting a non-expert school IT person. Target: from `git clone` to running app in under 10 commands. +- **Performance**: fast on modest hardware — SQLite + server-rendered pages where sensible (Next.js SSR/RSC) to keep client bundle small; paginate long lists (student rosters, entry timelines). +- **Backups**: document and script a simple daily backup (cron job copying the SQLite file + generated files directory to a backup location); consider using `litestream` or SQLite's `.backup` command and mention it as a recommended addition. +- **Security**: input validation on every API boundary (e.g., Zod schemas), parameterized queries only (ORM prevents SQL injection by default — don't bypass with raw string interpolation), secure cookie flags, rate limiting on auth endpoints, dependency vulnerability scanning in CI. +- **Licensing**: since this is a free/donation project, ship it under an OSI-approved open-source license (recommend MIT or AGPLv3 — explain the trade-off: AGPL prevents someone from taking this code and reselling it as a closed SaaS without contributing back, which may suit a "for the school, not for profit" mission better than MIT; let the team choose). +- **CI/CD**: GitHub Actions workflow running lint, typecheck, unit tests, and E2E smoke tests on every PR. + +--- + +## 9. Project Structure (suggested) + +``` +/app # Next.js App Router pages (or /src/app) + /(auth) + /(dashboard) + /api +/components +/lib + /db # Prisma/Drizzle client, schema + /grading # pure grading calculation functions + tests + /reports # report generation + PDF rendering + /permissions # authorization helpers +/prisma (or /drizzle) + schema.prisma + seed.ts + migrations/ +/docs + ARCHITECTURE.md + SELF_HOSTING.md + DATA_MODEL.md # ER diagram + entity descriptions + CONTRIBUTING.md +/tests + unit/ + e2e/ +docker-compose.yml +.env.example +README.md +LICENSE +``` + +--- + +## 10. Deliverables & Definition of Done for v1 (MVP) + +1. Auth (register/login/reset, optional 2FA) working end-to-end. +2. School → SchoolYear → ClassGroup → Student → Enrollment CRUD, with CSV import for students. +3. At least 2 seeded starter competency templates (e.g., "Elementary Math – Grade 3" and "Elementary German – Grade 3") with categories, competencies, and a 4-level mastery scale. +4. Teachers can assign/clone a template to a class+subject, edit their school's copy. +5. Fast entry-logging UI from a class roster. +6. Assessments with bulk grid entry. +7. Competence Flower radar chart per student, computed from real entry data, with a configurable aggregation method. +8. Grading scheme + weight rules + computed grade with breakdown + manual override. +9. Report generation (draft → final → PDF) with templated narrative text, single and bulk (class) export. +10. Data export (JSON/CSV) and erasure flow, with audit logging. +11. i18n scaffolding with DE + EN complete for all implemented screens. +12. Dockerized self-hosting setup + docs, seed script for demo data. +13. Test suite: unit tests for grading logic and permissions (target meaningful coverage on these two modules specifically, not just an overall % target), Playwright E2E covering: register → create class → assign template → add students → log entries → generate report. +14. `README.md` explaining the project's mission (free tool built for/with a school, donation-supported) and how to contribute. + +--- + +## 11. Phase 2 / Stretch Goals (explicitly out of scope for v1, but document as future ideas in `docs/ROADMAP.md`) + +- AI-assisted (optional, user-triggered) drafting of report narrative text, clearly marked as a draft for teacher review — never auto-finalized. +- SSO (OIDC) support for schools with existing identity providers. +- Parent portal (read-only view of their child's competence flower and finalized reports). +- Kita/early-childhood observation module (as Digidoo has hinted at on their roadmap). +- Optional Postgres deployment mode for larger multi-school instances. +- Native offline-first PWA mode for spotty classroom Wi-Fi. + +--- + +## 12. Instructions for the AI Coding Agent + +When you start implementing: +1. First produce `docs/ARCHITECTURE.md` and the full Prisma/Drizzle schema (§4) and get my confirmation before writing app code. +2. Then scaffold the Next.js project, auth, and the seed script — get the demo environment runnable first. +3. Build features in this order: School/Class/Student setup → Competency templates → Entry logging → Assessments → Competence Flower → Grading → Reports → Data export/erasure. +4. After each feature, write its unit/E2E tests before moving to the next. +5. Keep commits small and descriptive; write a CHANGELOG. +6. Flag any point where you are uncertain about a competency/grading policy that varies by German state (Bundesland) — expose it as a configuration option rather than hardcoding one state's rules, and note the assumption in `docs/DATA_MODEL.md`. +7. Remember throughout: **this is a free, non-commercial, donation-supported school project.** Prefer boring, dependency-light, self-hostable solutions over anything that introduces a paid third-party service or vendor lock-in. + + +# PRD v2 — "SchoolOS" (formerly "CompetenceTrack") +### A Free, Self-Hosted, All-in-One School Platform (Digidoo-inspired competency core + full school operations) + +--- + +## 0. Mission & Non-Negotiable Principles + +This is a **free, donation-supported school project**, not a commercial SaaS. It is being built for a real school; any support it receives goes back to the school. These principles override every design decision below: + +1. **DB: SQLite.** Single-file, self-hostable, cheap to run and back up. (One exception, scoped narrowly: an embedded vector store for AI search — see §9.6 — which can also run fully local/in-process.) +2. **No hardcoding, anywhere.** Every list, label, rule, threshold, escalation window, warning count, template, and permission must come from the database or an admin-editable config table — never a constant baked into code. If a value could plausibly differ between two schools or change next semester, it is a DB row, not a literal. +3. **No mock data, ever** — not in dev, not in demo, not in tests-as-shipped-UI. Seed scripts may create *realistic sample rows* for local development (clearly documented as seed data, easily wiped), but the shipped application must never silently render placeholder/fake data as if real. +4. **No duplicate functionality across pages.** Every capability lives in exactly one canonical place. Example: anything date/deadline/appointment-related belongs only on the **Calendar** page — not also embedded as a mini-widget with its own logic elsewhere unless it's a true *read-only summary widget pulling from the same single source* (see §8, Dashboard Widgets — a widget is a view, never a second implementation). Before adding any feature, check whether an existing page/module already owns that responsibility; if so, extend it there or surface it as a widget, don't rebuild it. +5. **Tablet-first, touch-first**, with full parity and a well-adapted layout for desktop/PC. This is the primary device class for teachers and students in-classroom. +6. **Everything is dynamic and role/permission-aware**, driven by real DB relationships (school → year → class → subject → student → user), never assumed. +7. Open-source license recommended: **AGPLv3** (prevents a third party from taking this free/community project and reselling it closed-source without contributing back) — team to confirm. + +--- + +## 1. Module Map + +The platform is organized into cohesive modules, each owning its data and UI completely (principle #4 above). This PRD defines each module's scope precisely so responsibilities never blur. + +| # | Module | Owns | +|---|--------|------| +| A | **Academics Core** | Competency grids, learning-progress entries, assessments, competence flower, grading, report cards *(carried over from PRD v1 — see §2, condensed)* | +| B | **Signage** | TV/billboard displays: announcement slides, sponsor slides, emergency/fire/counseling alerts | +| C | **Communication** | Direct messaging, escalation rules, class/grade social rooms, class-hours messaging locks | +| D | **Learning Hub** | Videos, interactive exercises, worksheets, vocab trainer, learning game, AI-assisted content authoring | +| E | **AI Tutor** | In-context homework help, hint-only policy, full lockout during tests, content translation for foreign students | +| F | **Exam Mode** | Kiosk/lockdown test-taking, teacher live controls, student warning system, incident log | +| G | **Digital Notebook** | Typed + stylus/pen notebook per student per subject, teacher pen annotation on tests/homework | +| H | **Notifications** | All push/in-app notifications, the *only* place alerts render outside their originating context banner | +| I | **Calendar** | All dates/deadlines/appointments/exam schedules/report deadlines — the *only* source of truth for "when" | +| J | **Dashboard & Widgets** | Per-user customizable home screen assembled from read-only widgets pulling from A–I | +| K | **Identity & Admin** | Users, roles, schools, classes, disciplinary records, audit log, permission/rule configuration | + +Every module below lists: purpose, roles involved, key entities, core flows, and explicit rules given by the school. + +--- + +## 2. Module A — Academics Core (condensed; full detail in PRD v1) + +Unchanged from the original spec: **CompetencyTemplate → CompetencyCategory → Competency → MasteryLevelDefinition**, **LearningProgressEntry**, **Assessment/AssessmentResult**, **GradingScheme/GradingWeightRule/ComputedGrade**, **Report/ReportSection**. See PRD v1 §4 and §6.1–6.7 for the full entity list and user stories — carry those forward unchanged. New in v2: + +- Assessment results integrate with **Exam Mode** (Module F): an Assessment can be flagged `deliveryMode = DIGITAL_LOCKED`, which triggers the kiosk/lockdown flow when a student starts it. +- Reports can optionally embed the student's **Digital Notebook** entries for the period as an appendix (teacher-selectable, off by default for privacy/size). + +--- + +## 3. Module B — Digital Signage (TV / Billboards) + +**Purpose:** Replace physical bulletin boards with school-controlled TV displays for announcements, class/period info, sponsor content, and — critically — emergency alerts. + +**Roles:** School Admin/Management authors & publishes; Sponsor content is admin-approved only (no self-serve); Teachers can request a slide be added (goes to an admin approval queue, not published directly) unless the school configures otherwise (config-driven per §0.2). + +**Entities:** +- `SignageDisplay` — id, schoolId, location/name (e.g., "Main Hall Screen 1"), resolution profile, isActive. +- `SignageSlide` — id, schoolId, type (enum: ANNOUNCEMENT, SPONSOR, INFO, SCHEDULE), title, body/media, priority, startAt, endAt, createdByUserId, approvalStatus. +- `SignagePlaylist` — id, displayId (or "all displays"), ordered list of slides, rotation duration per slide. +- `EmergencyAlert` — id, schoolId, type (enum: FIRE, LOCKDOWN, MEDICAL, GENERAL_EMERGENCY, COUNSELING_NOTICE), message, triggeredByUserId, triggeredAt, resolvedAt, affectedScope (whole school / specific building / specific class). + +**Core flows:** +- Normal rotation: displays cycle through active, date-windowed slides per their playlist, weighted by priority. +- **Emergency override (highest priority, non-negotiable):** any `EmergencyAlert` created by an authorized user (Admin, or any Teacher for fire/medical — configurable per school policy) **immediately interrupts every display's rotation**, shows a full-screen alert with type-specific styling (e.g., red/fire iconography for FIRE), and simultaneously fires a push notification (Module H) to all staff devices, and, for FIRE/LOCKDOWN, to all logged-in student/parent devices per school policy. Alert clears only when explicitly resolved by an authorized user, logged in AuditLog. +- **Counseling notice**: a lower-urgency variant — visible only on staff-facing displays/dashboards, not broadcast school-wide, used e.g. to flag "counselor needed in room X" without alarming the whole building. +- Sponsor slides: admin uploads, sets a date window and rotation weight; never auto-published from a teacher request. + +**Rule from the school:** any teacher-submitted slide must pass admin approval before appearing — build this as a queue with approve/reject + optional edit, not a silent auto-publish, unless the admin later flips a config flag to allow trusted teachers to self-publish INFO-type slides for their own class. + +--- + +## 4. Module C — Communication (Messaging & Escalation) + +This is the most rule-heavy module. Implement the escalation logic as **data-driven policy rows**, not hardcoded if/else chains, so admin can adjust thresholds without a code change. + +**Entities:** +- `MessageThread` — id, schoolId, type (enum: DIRECT, CLASS_ANNOUNCEMENT, SOCIAL_ROOM), participantsOrScope. +- `Message` — id, threadId, senderId, body, sentAt, readReceipts. +- `EscalationPolicy` — id, schoolId, fromRole, toRole, requiredPriorContactRole, waitingPeriodBusinessDays (default 3), isActive, overriddenByUserId (nullable, for admin-configured exceptions). **All numbers here (the "3 business days") are read from this table, never hardcoded.** +- `EscalationRequest` — id, studentId or parentId (requester), subject, firstContactThreadId, firstContactAt, eligibleToEscalateAt (computed = firstContactAt + policy.waitingPeriodBusinessDays business days, using the school calendar to skip holidays), status (enum: WAITING, ELIGIBLE, ESCALATED, RESOLVED). +- `SocialRoom` — id, schoolId, scope (enum: CLASS, GRADE_LEVEL/"Stufe", CUSTOM_GROUP), name, moderatorUserIds, isActive. +- `ClassMessagingLock` — id, classGroupId, lockedByUserId (the teacher), lockedFrom, lockedUntil (nullable = "until class ends," or explicit time) — while active, students in that class cannot send/receive in student-facing threads (staff-to-student announcements still deliver). +- `DisciplinaryRecord` — id, userId (student or, separately, staff-disciplinary variant), type, description, issuedByUserId, linkedIncident (nullable FK to an ExamWarning or a reported message), visibleToParent (bool), createdAt. + +**Core rules (exactly as specified by the school, encoded as the default `EscalationPolicy` rows — admin can edit or disable):** +- **Parent → Admin:** allowed only for genuinely necessary matters, and only after the parent has messaged the student's **homeroom teacher** about that subject at least once. If no reply within **3 business days**, the parent gains the right to message Admin about that specific thread (system auto-creates/unlocks the Admin channel on that `EscalationRequest`, referencing the original thread for context). +- **Student → Admin/Counseling:** same pattern — student must first message the homeroom teacher; after 3 business days without reply, gains the right to escalate to the counseling unit ("Rehberlik" — modeled as a `Counselor` role/department) and/or Admin. +- **Teacher → Admin:** direct, no escalation gate required. +- **Admin/Management override:** Admin can loosen, tighten, or remove any `EscalationPolicy` row per school, per role pair, or per individual case (`overriddenByUserId` + reason, logged). Admin can also issue a `DisciplinaryRecord` against a teacher or student directly from a messaging or exam-incident context. +- **Social rooms:** class-scoped or grade/"Stufe"-scoped group discussion spaces ("mini Facebook"), moderated by assigned staff; posts/threads, not just DMs. Config per school: which scopes are enabled, whether posts need moderation approval before visible. +- **Class-hours lock:** a teacher can toggle `ClassMessagingLock` for their class; while active, students in that class are blocked from sending in student↔student and student↔staff threads (but can still *receive* urgent Signage/Notification-level alerts) until the teacher lifts it or class period ends (integrate with Module I's period/timetable data to auto-lift at period end if the teacher doesn't manually lift it — config toggle for auto vs. manual). + +**Business-day computation** must read the school's holiday/weekend calendar from Module I (Calendar), not assume a fixed Mon–Fri with no holidays. + +--- + +## 5. Module D — Learning Hub (Student Self-Study Resources) + +**Purpose:** A curriculum-aligned self-study resource center (functionally inspired by tools like Sofatutor — do not copy their branding, exact video content, or exact wording; build original or licensed/open content, organized the same *way*: by subject and grade level). + +**Entities:** +- `LearningResource` — id, schoolId (nullable = shared/global library), subjectId, gradeLevel, type (enum: VIDEO, INTERACTIVE_EXERCISE, WORKSHEET, VOCAB_SET, GAME_MODULE), title, description, contentRef (file path or embed URL), competencyLinks (many-to-many to `Competency`, so resources surface contextually — e.g., from a student's weak competency-flower petal, "here's a resource to help"), createdByUserId (teacher/admin/AI-assisted), reviewStatus (enum: DRAFT, PENDING_REVIEW, PUBLISHED), aiGenerated (bool). +- `WorksheetSolution` — attached to a WORKSHEET resource, hidden by default, revealable by student after attempt or by teacher setting. +- `VocabSet` / `VocabItem` — user-entered or teacher-provided vocab lists feeding the vocab trainer's spaced-repetition practice loop. +- `GameProgress` — per-student progress/score state for the learning game module, scoped by subject. + +**Core flows:** +- Teachers/Admin can create, edit, or request AI-assisted drafting of a resource (AI drafts → always lands in `PENDING_REVIEW`, never auto-published — a human must approve, consistent with §0.2's "no silent hardcoded/auto content" spirit and basic content-quality control). +- Resources are discoverable by subject/grade browse, search, **and contextually** — surfaced automatically next to a student's weak competency in Module A's Competence Flower and in AI Tutor suggestions (Module E). +- Printable worksheets export as PDF with an optional teacher toggle to include/exclude the solutions page. + +--- + +## 6. Module E — AI Tutor & AI Content Assistance + +**Purpose & hard policy (as specified by the school — implement as enforced server-side logic, not just prompt instructions, since this is a safety/integrity requirement, not a suggestion):** + +- The AI tutor **may**: explain a concept, walk through a *similar* worked example, give a hint on where a student is stuck, define a term, and — for a specific single question a student is stuck on — nudge them toward the next step. +- The AI tutor **must never**: produce a direct final answer to an assigned homework/assessment question, complete an assignment on the student's behalf, or operate at all while any `Assessment` with `deliveryMode = DIGITAL_LOCKED` is active for that student (hard gate — see Module F; the AI chat endpoint checks "is this student currently inside an active exam session?" and refuses if so, independent of any client-side UI state). +- Every AI Tutor interaction is logged (`AiTutorInteraction`: id, studentId, subjectId/competencyId if applicable, promptSummary, responseSummary, timestamp, flaggedAsHomeworkAttempt bool) so teachers/admin can audit for misuse patterns, without necessarily storing full raw chat transcripts if the school prefers privacy-minimized logging (config toggle: summary-only vs full-transcript retention, with a retention period). +- **Translation:** AI-powered on-demand translation of learning content and (optionally) UI copy for foreign-language students, requested per-resource or per-message, with the translated text clearly labeled "AI-translated" and a way to flag a bad translation for staff review. + +**Content-authoring assist (Module D bridge):** the same AI capability can be invoked by teachers/admin to draft Learning Hub resources or Signage announcement copy — always landing in a review/draft state, never publishing directly. + +--- + +## 7. Module F — Exam Mode (Digital Lockdown & Proctoring) + +**Purpose:** A secure, kiosk-style test-taking mode for digitally delivered assessments, plus the in-test behavior/warning system the school specified. + +**Entities:** +- `ExamSession` — id, assessmentId, studentId, deviceId, startedAt, status (enum: ACTIVE, PAUSED, RESUMED, ENDED, CANCELLED), pausedByUserId (nullable), pauseReason (enum: BATHROOM_REQUEST, TEACHER_INITIATED, STUDENT_DISTRESS, THIRD_WARNING, OTHER). +- `ExamWarning` — id, examSessionId, issuedByUserId (teacher), reason (free text or preset e.g. "talking," "looking at other screen"), sequenceNumber (1st/2nd/3rd), issuedAt. +- `ExamIncidentEvent` — id, examSessionId, type (enum: LOCK_BREACH_ATTEMPT, BATHROOM_REQUESTED, BATHROOM_GRANTED, DISTRESS_SIGNAL, PAUSED, RESUMED, CANCELLED), metadata, timestamp. + +**Core flow (exactly as specified):** +1. When a student opens a `DIGITAL_LOCKED` assessment, the client enters **kiosk/fullscreen lockdown**: the device locks to the exam UI only — no other tabs/apps, no AI Tutor access (hard-gated per Module E), timer visible per assessment config. +2. **Bathroom request:** student taps a "request break" control → teacher gets a real-time alert (Module H) → if the teacher approves, that student's individual `ExamSession` pauses (other students' sessions are unaffected) → resumes when the teacher (or the student, per config) marks return. +3. **Distress signal:** student can send an "I feel unwell / need help" signal at any time → immediate high-priority alert to the teacher (distinct styling/sound from a routine bathroom request) → teacher attends; if unresolved, teacher can pause or fully cancel that student's exam (cancellation reason required, logged, visible to parent/admin). +4. **Behavior warnings:** teacher can issue an `ExamWarning` to a specific student mid-test (e.g., for talking or looking at another student's screen). 1st and 2nd warnings are logged and shown to the student as an alert; the 2nd is explicitly flagged as "final warning." On the 3rd, the system **auto-pauses** that student's exam; the teacher then decides to resume or end it. Every warning and the resulting record is stored on the student's disciplinary/record trail, visible to parents and admin (reuses `DisciplinaryRecord` from Module C, linked via `linkedIncident`). +5. Teacher has a live **class exam-monitor view**: every student's session status (active/paused/warned) at a glance, one-tap pause/resume/cancel/warn per student. +6. All lockdown enforcement must be **defense-in-depth**: client-side kiosk/fullscreen API + Workbox-based service worker to intercept navigation attempts, *and* a server-side session flag that independently blocks other API calls (AI Tutor, Learning Hub, Messaging) for the duration — never trust the client alone for an integrity feature. + +--- + +## 8. Module G — Digital Notebook + +**Purpose:** Digital replacement for the paper exercise notebook — environmental motivation stated explicitly — while supporting Germany's range of school notebook/exercise-book conventions. + +**Entities:** +- `NotebookTemplate` — id, schoolId (nullable = global), name, ruling type (e.g., lined, squared/Kariert, blank), gradeLevel/subject applicability — mirrors the real German notebook types (Lineatur) so pages "feel" like the paper equivalent students already know. +- `NotebookPage` — id, studentId, subjectId, classGroupId, templateId, createdAt, content (structured: typed text blocks + vector "ink" strokes for stylus input, stored e.g. as SVG/stroke-JSON, not baked raster images, so it stays editable/searchable/annotatable). +- `NotebookAnnotation` — id, pageId (or linked to an Assessment submission), authorUserId (teacher), strokeData/comment, createdAt — used for teacher correction marks/pen feedback directly on a test or notebook page. + +**Core flows:** +- Students write via keyboard and/or stylus (pen) — both can coexist on the same page (e.g., typed answer + hand-drawn diagram). +- Teachers annotate directly on a digital test submission or notebook page with pen input — highlighting, circling, margin comments — captured as its own `NotebookAnnotation` layer so the original student work is never overwritten (non-destructive review, mirroring how a teacher marks a real paper test in red pen). +- Notebook pages are organized per subject per student per school year, browsable like a real notebook (page-by-page / date-by-date), exportable as PDF. + +--- + +## 9. Cross-Cutting Modules + +### 9.1 Module H — Notifications (single source of alerts) +All push/in-app alerts — messaging, exam warnings/bathroom requests, signage emergencies, escalation-eligibility notices, learning-hub review-queue items — route through **one** `Notification` entity and **one** delivery pipeline (browser push via the Web Push API, service-worker-delivered per **Workbox**, plus an in-app notification center). No module renders its own separate "toast/badge" system outside this pipeline — per the no-duplication rule (§0.4), every other module *emits* into Module H rather than building its own alert UI. + +`Notification` — id, recipientUserId, type, priority (enum: NORMAL, URGENT, EMERGENCY), title, body, deepLinkRef (points back to the owning module/entity), createdAt, readAt, deliveredChannels (JSON: push/in-app/etc). + +### 9.2 Module I — Calendar (single source of "when") +All dates live here: class timetable/periods, exam schedules, report-card deadlines, school holiday calendar (feeds Module C's business-day math), signage slide date windows (referenced, not duplicated — Calendar shows a read-only reflection sourced from `SignageSlide.startAt/endAt`, it doesn't own a second date field). Same non-duplication logic applies to every other module with a date. + +### 9.3 Module J — Dashboard & Widgets +Every user role gets a **customizable home dashboard**: add/remove/reorder widgets from an approved catalog (each widget is a thin, read-only view into one owning module — e.g., "Today's Notifications," "My Classes' Exam Sessions Live," "Upcoming Calendar Items," "Class Competence Flower Summary," "Learning Hub: resources pending review"). Widget layout persists per user (`UserDashboardLayout` — userId, widgetInstances JSON: widgetType, position, size, config). Widgets never contain their own business logic beyond fetching/rendering — this enforces §0.4 structurally, not just by convention. + +### 9.4 Module K — Identity, Roles & Admin +Extends PRD v1's role model (Teacher / School Admin / Super Admin) with: +- **Counselor** ("Rehberlik") role — receives escalated student/parent threads per Module C. +- **Parent** role — read-only into their child's reports/grades/competence flower (from Module A), messaging per Module C's rules, visibility into `DisciplinaryRecord` entries flagged `visibleToParent`. +- **Student** role — Learning Hub, AI Tutor (gated), Exam Mode, Digital Notebook, Communication (gated by escalation rules and class locks). +- **Sponsor** is not a login role — sponsor content is admin-managed only (per Module B). +- All escalation thresholds, warning limits, kiosk enforcement toggles, and messaging rules are exposed in a single **Admin Policy Console** so a non-technical admin can tune the whole rules engine without touching code — this is the concrete implementation of "admin can loosen/tighten/remove these rules." + +### 9.5 Touch/Tablet UI-UX Overhaul +- Minimum touch target 44×44pt (WCAG/Apple HIG baseline), larger on primary actions (exam controls, entry-logging buttons). +- Enlarged cards/list rows in tablet breakpoint, denser layout on desktop breakpoint — one responsive design system, not two separate codebases. +- **Kiosk/fullscreen mode**: implement via the Fullscreen API + a Workbox-powered service worker (offline shell caching, reliable "install as app" PWA behavior, and push notification delivery even when the browser tab isn't focused). +- Free browser push notifications: Web Push API, no paid push-notification vendor required. + +### 9.6 AI/Search Infrastructure Note (re: suggested repos) +- **Workbox** (GoogleChrome/workbox): use for the PWA service worker — offline shell caching, kiosk-mode reliability, and Web Push delivery (Module H, §9.5). This is the clearest direct fit. +- **Comlink** (GoogleChromeLabs/comlink): use to offload heavy client-side work (e.g., stylus stroke rendering/processing in the Digital Notebook, or client-side AI-translation pre/post-processing) to a Web Worker via a simple RPC-style API, keeping the main thread responsive for touch input — directly supports the "touch performance" requirement. +- **Chroma** (chroma-core/chroma): an embeddable vector database, usable to power semantic search over Learning Hub resources and as retrieval-augmented context for the AI Tutor (so it answers "using this school's actual curriculum materials" rather than generic knowledge). Can run embedded/local — keep it as an optional, swappable component behind an interface, not a hard dependency, so a school without AI features enabled never needs to run it. **Primary relational data stays in SQLite regardless** (§0.1) — Chroma, if used, only stores content embeddings for search/retrieval, not core school records. + +--- + +## 10. Updated Non-Functional Requirements + +- Reconfirm §0's five principles apply to **every module above**, not just Module A. +- Security posture from PRD v1 (§8) still applies, plus: Exam Mode integrity must be defense-in-depth (§7.6); Emergency Alerts (§3) must have the lowest possible latency path — treat as a "fire drill"-grade reliability requirement, test failure/retry behavior explicitly. +- Data minimization: AI Tutor logging defaults to summary-only (§6); Digital Notebook stroke data belongs to the student and must be exportable/erasable like any other student record (PRD v1 §6.9). +- Accessibility (WCAG 2.1 AA) applies across all new modules, including Signage (captioned/legible from a distance) and Exam Mode (must remain usable with assistive tech even inside lockdown). + +--- + +## 11. Revised MVP Phasing + +Given the expanded scope, ship in **stages** rather than one giant release — each stage fully functional and demo-able: + +**Stage 1 — Foundation (carries PRD v1 MVP):** Identity/roles/schools/classes/students, Module A (Academics Core) end-to-end, Calendar (Module I) as the shared date backbone, Notifications (Module H) skeleton, Dashboard/Widgets (Module J) with 2–3 widgets. + +**Stage 2 — Communication & Signage:** Module C in full (messaging, escalation policy engine, social rooms, class lock), Module B (signage, including Emergency Alert override), wired into Module H/I. + +**Stage 3 — Learning & AI:** Module D (Learning Hub, manual authoring first, AI-assist as a fast-follow), Module E (AI Tutor with hard test-time gating from day one — never ship AI Tutor before Exam Mode's gate exists, to avoid ever shipping a homework-solving loophole). + +**Stage 4 — Exam Mode & Digital Notebook:** Module F (kiosk lockdown, warnings, monitor view) and Module G (typed + stylus notebook, teacher annotation) — these are the highest-integrity/most device-sensitive modules, saved for last so the touch/kiosk foundation (§9.5) is mature first. + +Each stage gets its own seed data, its own test suite additions, and its own entry in `docs/ROADMAP.md`. + +--- + +## 12. Instructions for the AI Coding Agent (updated) + +1. Re-confirm the full schema (PRD v1 §4 + all new entities in §2–9 above) and get sign-off before writing feature code — the schema is now large enough that a wrong early decision is expensive. +2. Build a single shared **Policy/Config layer** (backing Module K's Admin Policy Console) *before* implementing Module C's escalation logic or Module F's warning thresholds — both must read from it, proving the "no hardcoding" rule structurally rather than by discipline alone. +3. Build Module H (Notifications) and Module I (Calendar) early, in Stage 1, precisely because every later module depends on them — this prevents each subsequent module from inventing its own notification/date logic (the exact duplication the school explicitly forbade). +4. When implementing Module F (Exam Mode), write the server-side session-gating check (§7.6) *before* the pretty kiosk UI — the security property must not depend on the UI shipping correctly. +5. Flag explicitly, in `docs/DATA_MODEL.md`, every place a rule/threshold is read from a policy table (so a future audit can confirm zero hardcoded business rules). +6. Follow PRD v1 §12's remaining process guidance (small commits, tests per feature, changelog, boring/dependency-light choices) unchanged. + +# PRD v3 — Addendum: Module L — Legal, Data Protection & Rights Compliance (EU/DE) + +This is an addendum to PRD v1 (Academics Core) and PRD v2 (full module map A–K). It adds **Module L**, which every other module must satisfy, and updates several existing entities to be compliant with **EU GDPR (DSGVO)**, the **German Federal Data Protection Act (BDSG)**, and — critically, because schools are public bodies — the **state-level School Acts (Landesschulgesetze) and School Data Protection Ordinances (Schul-Datenschutzverordnungen)**, which vary across all 16 German Bundesländer and often override or supplement the GDPR baseline for public schools. + +> **Hard disclaimer to carry into the product and into `docs/`:** This PRD gives the *engineering* requirements needed to make compliance *possible*. It is not legal advice, and this document (nor any AI tool) can make the final legal determination for a specific school. Every school deploying this platform **must** involve its own **Datenschutzbeauftragter (DPO)**, its **Schulträger** (school authority), and — for co-determination matters — its **Personalrat/Lehrerrat** before go-live. Encode this as a literal onboarding gate in the product (§L.9). + +--- + +## L.1 Governing Framework (what applies, and why it's layered) + +1. **GDPR (DSGVO)** — the EU-wide baseline: lawful basis (Art. 6), special category data (Art. 9), children's data (Art. 8), data protection by design/default (Art. 25), records of processing (Art. 30), DPIA (Art. 35), data subject rights (Art. 12–22), processor agreements (Art. 28). +2. **BDSG** — German federal implementing law, adds specifics (e.g., § 26 BDSG on employee data processing — relevant to teacher accounts, audit logs, and any monitoring capability). +3. **Landesschulgesetz** (state School Act) of the school's Bundesland — this is usually the actual **legal basis** for processing student data in a public school (Art. 6(1)(c)/(e) GDPR: legal obligation / public task), **not** consent — consent is often the *wrong* legal basis for a school-student relationship because of the power imbalance, except for genuinely optional features (e.g., opting into the Learning Hub's game leaderboard, or the newsletter-style features). +4. **Schul-Datenschutzverordnung / VO-DV I / equivalent** per state — the operative rules on retention periods, which data categories a school may collect, video/photo rules, cloud-hosting rules, and pupil-file (Schülerakte) handling. **These differ by state and must not be hardcoded** (§L.6). +5. **Landespersonalvertretungsgesetz (LPVG) / BPersVG** — governs **co-determination rights of the staff council (Personalrat)** for public-sector employees (teachers are usually civil servants/public employees) — this is the legal hook for **teacher rights** around any system that can monitor behavior or performance (§L.4). +6. **KJSG / Kinderrechte / UN-Kinderrechtskonvention** — general child-rights framework informing "best interests of the child" design choices (age-appropriate UI, no dark patterns, no manipulative engagement mechanics in the Learning Hub's game module). + +--- + +## L.2 Data Protection by Design & by Default (Art. 25) — mapped to existing modules + +Apply these to every module from PRD v2, not just new ones: + +- **Data minimization at the schema level:** re-audit every entity in Modules A–K; any optional field must default to *not collected*. Example already flagged in PRD v1 §4: `Student.dateOfBirth` is nullable — keep it that way, and only populate it if the school's state rules actually require it for the pupil file. +- **Purpose limitation:** each table/module must document, in `docs/DATA_MODEL.md`, the specific legal basis and purpose for that data (e.g., `ExamWarning` → purpose: exam integrity & pedagogical record; legal basis: Schulgesetz duty to ensure fair assessment conditions). +- **Pseudonymization where feasible:** e.g., `AiTutorInteraction` (Module E) should store a pseudonymous student reference plus subject/competency context by default, resolvable to the real student only through an access-controlled join, not stored redundantly in logs/exports. +- **Storage limitation:** every entity with personal data needs a `retentionPolicyRef` pointing to Module L's retention config (§L.6), and a scheduled job that flags/executes deletion once the retention period lapses — not a manual "someone remembers to clean this up" process. +- **Access control by default = deny:** reaffirm PRD v1 §5's authorization-by-role, extended: a Teacher's default access to a student is **scoped to classes they currently teach that student in** — losing that assignment (end of enrollment/school year) must automatically narrow their access, not require a manual revoke. + +--- + +## L.3 Special-Category & Sensitive Data (Art. 9 GDPR) — flag every touchpoint + +Several features from PRD v2 can touch **special category data** (health, and potentially religion/ethnicity if ever captured, though this platform should **never** collect race/ethnicity/religion/sexual orientation/union membership/political opinion — explicitly out of scope, no field for it anywhere in the schema): + +- **Exam "distress signal"** (Module F, §7): a student signaling they feel unwell during a test is **health-adjacent data**. Requirements: + - Store only a minimal event flag (`ExamIncidentEvent.type = DISTRESS_SIGNAL`) plus optional free text the student/teacher chooses to add — never a structured "symptom" or diagnosis field. + - Restrict visibility to the involved teacher + admin/counseling on a need-to-know basis; do **not** surface it on general dashboards or to other teachers by default. + - Short default retention unless the incident escalates into a formal record (e.g., leads to an actual accommodation or medical event requiring documentation) — configurable per school policy (§L.6), but default to the shortest period that satisfies the school's duty of care. +- **Disciplinary records** (`DisciplinaryRecord`, Module C/F) are not Art. 9 special-category data per se, but are highly sensitive personal data about a minor — apply the same need-to-know visibility model, and make `visibleToParent` a real, enforced access-control flag, not just a UI toggle. +- **Counseling notices** (Module B, §3) must never broadcast identifying detail on general-audience displays — the whole point of the "counseling notice ≠ emergency alert" distinction in PRD v2 is to avoid exposing a student's need for support to the wider school. +- **Foreign-student translation** (Module E): a student's home language, inferred from translation requests, is not classic special-category data, but is a data point that can reveal national origin — treat with the same minimization discipline (don't build a persistent "ethnic/linguistic profile," just serve the translation request and discard the routing metadata beyond what's needed for the UI session). + +--- + +## L.4 Teacher Rights & Staff Co-Determination (Personalrat / Unions) + +This is a distinct legal track from student data protection — it protects **teachers**, and it is a **hard gate**, not a nice-to-have: + +- **Any feature capable of monitoring staff behavior or performance** — this plausibly includes: `AuditLog` entries on teacher actions, message-read receipts, exam-monitor dashboards showing how promptly a teacher responds to a bathroom/distress request, Learning Hub content-authoring approval/rejection rates, or any future "teacher activity" analytics widget — **triggers co-determination rights of the Personalrat (staff council)** under the relevant state's LPVG (or, for federal matters, BPersVG). In the private-sector analogy this is the same principle as § 87(1) No. 6 BetrVG. +- **Product requirement:** before any such feature ships to a real school, it must be documented in a **Dienstvereinbarung-ready spec sheet**: what is logged, who can see it, how long it's kept, and what it will/won't be used for (explicitly: not for unilateral performance evaluation without the Personalrat's agreement). Ship this spec sheet as a generated doc per module (`docs/personalrat/.md`), not just prose buried in this PRD. +- **Union involvement:** recommend the school proactively loop in its **GEW (Gewerkschaft Erziehung und Wissenschaft)** or **VBE** representative and its Personalrat during rollout — not a product feature per se, but the onboarding flow (§L.9) should surface this as a required checklist item before enabling any monitoring-capable module. +- **Teacher data subject rights:** teachers have the same GDPR rights as anyone (access, rectification, erasure where applicable, objection) over their own account data, message logs, and audit trail — build a self-service "my data" export for staff accounts, same mechanism as the student-facing one (PRD v1 §6.9), reused rather than duplicated (per the no-duplication rule). +- **Working-time/communication boundary:** the class-hours messaging lock (Module C) and any after-hours notification behavior should be configurable so the platform doesn't itself become a vector for pressuring teachers to be reachable outside contracted hours — expose a per-teacher "quiet hours" setting for non-urgent notifications (urgent/emergency alerts from Module B always bypass this, by design). + +--- + +## L.5 Student Rights + +- **Right of access (Art. 15):** a student (or, for younger students, their parent/guardian on their behalf per §L.7) can request and receive a full export of their data — competency entries, grades, notebook pages, messages, disciplinary records, AI Tutor interaction summaries — reusing the Module K data-export pipeline (PRD v1 §6.9), not a bespoke second exporter. +- **Right to rectification (Art. 16):** factual errors (wrong competency entry, mis-recorded assessment score) must be correctable via the existing edit/audit-logged flows already specified in Module A — no new mechanism needed, just confirm the audit trail satisfies "who changed what, when, why." +- **Right to erasure (Art. 17), bounded by retention duties:** a student's *account* and *actively used* data can be erased on request or on leaving the school, **except** for records the school is legally obligated to retain (e.g., final grades/report cards for the statutory retention period — varies by state, see §L.6). Implement as: erasure request → system erases everything **not** covered by an active retention policy, and clearly reports back what was retained and why (transparency even in partial erasure). +- **No solely-automated decisions with legal/significant effect (Art. 22):** `ComputedGrade` (Module A) must always have a human teacher confirm/finalize it before it becomes an official grade — this is already implied by PRD v1's "teacher can override" design; make it a **hard requirement**, not optional: a `ComputedGrade` cannot reach `isFinalized = true` without a `finalizedByUserId` (a human). Same principle for `ExamWarning`'s third-strike auto-pause (Module F): the **pause** can be automatic (it's a safety/integrity mechanism, not a "decision about the student" in the Art. 22 sense), but any resulting **disciplinary consequence** requires a human (teacher/admin) to actually create the `DisciplinaryRecord` — the system must never auto-generate a disciplinary entry without a human actor attached. +- **Protection from profiling beyond pedagogical purpose:** competency/AI-tutor data must not be repurposed into any kind of broader "student risk score" or similar without an entirely separate legal basis and DPIA — explicitly out of scope; flag this as a **forbidden feature**, not just an unbuilt one, in `docs/ROADMAP.md`'s "will not build" section. + +--- + +## L.6 Retention Periods — config-driven, not hardcoded (extends §0.2/§0.3 from PRD v2) + +German statutory retention periods for pupil records (Zeugnisse, Schülerakten, Prüfungsunterlagen, etc.) **vary by Bundesland** and sometimes by record type (e.g., final Abitur-related records often must be kept far longer than routine coursework). Do not hardcode a single number anywhere. + +- New entity: `DataRetentionPolicy` — id, schoolId, dataCategory (enum mapping to the entities above: LEARNING_PROGRESS, ASSESSMENT_RESULT, FINAL_REPORT, DISCIPLINARY_RECORD, EXAM_INCIDENT, MESSAGE, AI_TUTOR_LOG, NOTEBOOK_PAGE, SIGNAGE_LOG), retentionPeriodMonths, legalBasisNote (free text — admin/DPO fills in the state-specific rule they're relying on), reviewedByUserId, reviewedAt. +- Every module that writes personal data must resolve its retention period from this table at write time or via a scheduled sweep — never assume a number in application code. +- Ship the table **empty of defaults for legally-mandated categories** (force the school's DPO to actually fill in their state's correct numbers rather than shipping a guessed default that could be wrong) — but ship **sensible short defaults for non-mandated, product-only categories** (e.g., `AI_TUTOR_LOG` summary retention defaulting to something modest like 12 months, editable). + +--- + +## L.7 Parent Rights + +- **Information duty (Art. 13/14):** on account creation, parents/guardians receive a plain-language privacy notice (not just a legal-boilerplate one) covering what's collected, why, for how long, and who can see it — generated from the same `DataRetentionPolicy`/module documentation so it can never drift out of sync with what the system actually does. +- **Consultation/approval before rollout:** most German state School Acts require the **Schulkonferenz** and/or **Elternbeirat** to be consulted (and often to formally approve) before a school introduces new pupil-data-processing software. Build this as a literal onboarding checklist step (§L.9) — the product should not let an admin silently "switch on" a new high-impact module (Signage emergency broadcast to parents, Exam Mode, AI Tutor) without acknowledging this step happened. +- **Access to their child's records:** parents get read access to grades, reports, competence flower, and any `DisciplinaryRecord` flagged `visibleToParent` — already specified in Module A/C/F; this section just anchors it as a *legal right*, not a product nicety, so it's never gated behind a paywall or admin discretion in a way that would violate that right. +- **Messaging rights per Module C:** the parent escalation rule (parent → homeroom teacher first, 3 business days, then Admin) is a **product/pedagogical workflow rule**, not a restriction on the parent's underlying legal right to contact the school about their child — make sure the UI frames it as "here's the fastest effective path" and always leaves a true emergency/safety path available that bypasses the queue (tie into Module B's emergency alert / a dedicated "urgent safety concern" contact route that skips the escalation gate entirely). + +--- + +## L.8 School's Rights & Obligations (Controller Responsibilities) + +- **Controller vs. processor:** the school (via its Schulträger) is normally the **data controller**; if a self-hosted instance is operated by the school itself, the school is both controller and, practically, its own processor/operator (simplifies Art. 28 needs). If a third party (e.g., the agency building this, or a hosting provider) operates the instance *for* the school, an **Art. 28 Auftragsverarbeitungsvertrag (AVV)** is required — ship a template AVV in `docs/legal/avv-template.md`, clearly marked as a **starting draft for the school's DPO/lawyer to finalize**, not a ready-to-sign legal document. +- **Records of Processing Activities (Art. 30):** generate a `docs/legal/verzeichnis-verarbeitungstaetigkeiten.md` (RoPA) directly from the module/entity documentation — every module in PRD v2 already needs a purpose+legal-basis note (§L.2); assembling those into the formal Art. 30 register should be close to mechanical, not a separate research project. +- **DPIA trigger (Art. 35):** this platform, taken as a whole, plausibly meets the criteria for a **mandatory Datenschutz-Folgenabschätzung** — large-scale processing of children's data, systematic monitoring (Exam Mode's behavior warnings, Signage's emergency systems), and special-category-adjacent data (health signals). **Do not let a school go live with Modules F (Exam Mode) or E (AI Tutor) without a completed DPIA on file** — build this as an actual admin-facing gate: those modules stay disabled in the Admin Policy Console until a `DpiaRecord` (id, schoolId, moduleScope, completedAt, approvedByUserId, documentRef) exists for them. +- **Datenschutzbeauftragter (DPO) role:** add `DPO` as a permission profile in Module K — not a full admin, but someone with read access to the compliance surfaces (RoPA, retention policy table, DPIA records, audit logs across the school) and the specific power to **gate-approve** high-risk modules before they activate. + +--- + +## L.9 Product-Level Compliance Onboarding Gate + +Concretely implement the above as a **required onboarding flow for every new school instance**, before the platform is usable for real student data: + +1. Enter Schulträger/DPO contact details. +2. Fill in the state (Bundesland) → auto-loads that state's known retention-period *prompts* (labels only — the school must still confirm actual numbers; never silently assume) into `DataRetentionPolicy`. +3. Acknowledge Elternbeirat/Schulkonferenz consultation status per module before that module can be turned on for real use (a simple checklist, logged with who/when). +4. Acknowledge Personalrat consultation status for any monitoring-capable module (§L.4), same mechanism. +5. Complete/upload the DPIA before Modules E and F unlock (§L.8). +6. Only after these steps does the Admin Policy Console (PRD v2 §9.4) allow those modules to leave "disabled" state. + +This turns "must comply with EU/German data protection law" from a documentation aspiration into an actual enforced product flow — consistent with PRD v2 §0's "no hardcoding / everything DB-driven / dynamic" principle: **compliance status itself is DB-driven state that gates feature availability**, not a one-time audit that's forgotten after launch. + +--- + +## L.10 On the `Klotzkette/claude-fuer-deutsches-recht` reference repository + +This is a large, community-maintained collection of **Claude Code skills/prompts for German legal practice** (labor law, corporate law, insolvency law, **data protection law**, procedural law, and — relevantly — clusters touching **Schulrecht/Hochschulrecht**, school and higher-education law). It is explicitly published as an **experimental aid for legal drafting workflows**, not a certified compliance product, and its own README repeatedly disclaims that it gives no assurance of correctness or professional-rules compliance. + +**Recommended, bounded use for this project:** +- Use its `datenschutzrecht` (data protection law) skill as a **drafting assistant** when preparing the AVV template (§L.8), the RoPA structure (§L.8), and the parent-facing privacy notice (§L.7) — i.e., as a way to get a solid first draft faster, in the correct German legal style/structure. +- Do **not** treat any output from it (or from this PRD, or from Claude generally) as a substitute for sign-off by the school's actual Datenschutzbeauftragter or legal counsel — encode that caveat directly into any generated legal-adjacent document (`docs/legal/*.md` files should carry a header disclaimer). +- It is a **prompt/skill library**, not a code dependency — it has no runtime role in the application itself; keep it entirely out of the app's dependency tree and treat it purely as a tool the development team (or the school's admin/DPO) can consult while authoring the compliance documents in `docs/legal/`. + +--- + +## L.11 Updated Definition of Done (compliance-specific additions) + +On top of PRD v1 §10 and PRD v2 §11, a school instance is **not** considered production-ready until: + +- [ ] `docs/DATA_MODEL.md` has a purpose + legal-basis note for every entity holding personal data. +- [ ] `DataRetentionPolicy` rows exist (school-confirmed, not defaulted) for every legally-mandated category. +- [ ] `docs/legal/verzeichnis-verarbeitungstaetigkeiten.md` (RoPA) is generated and reviewed. +- [ ] AVV signed (if a third party operates the instance) or explicitly marked not-applicable (self-hosted-by-school). +- [ ] DPIA completed and on file before Modules E (AI Tutor) or F (Exam Mode) are enabled. +- [ ] Elternbeirat/Schulkonferenz consultation logged per module. +- [ ] Personalrat consultation logged for every monitoring-capable module. +- [ ] Student/parent/teacher self-service data-export flows tested end-to-end. +- [ ] Privacy notice generator produces an accurate, plain-language notice reflecting actual configured retention periods (no drift between docs and DB config). + +# PRD (Consolidated) — "SchoolOS" — A Free, Self-Hosted, All-in-One School Platform + +*(Digidoo-inspired competency core, expanded into a full school operations platform, fully EU/German data-protection compliant. This document merges and supersedes the three prior drafts — Digidoo-clone build prompt, School Platform PRD v2, and Legal Compliance addendum v3 — into a single reference PRD.)* + +--- + +## 0. Mission & Non-Negotiable Principles + +This is a **free, donation-supported school project**, not a commercial SaaS. It is being built for a real school; any support it receives goes back to the school. These principles override every design decision in this document: + +1. **DB: SQLite.** Single-file, self-hostable, cheap to run and back up. (One narrow exception: an optional embedded vector store for AI search — §9 — which can also run fully local/in-process.) +2. **No hardcoding, anywhere.** Every list, label, rule, threshold, escalation window, warning count, template, retention period, and permission must come from the database or an admin-editable config table — never a constant baked into code. If a value could plausibly differ between two schools, two German states, or change next semester, it is a DB row, not a literal. +3. **No mock data, ever** — not in dev, not in demo, not in shipped UI. Seed scripts may create *realistic sample rows* for local development (clearly documented as seed data, easily wiped), but the shipped application must never silently render placeholder/fake data as if real. +4. **No duplicate functionality across pages.** Every capability lives in exactly one canonical place (e.g., all dates live only in the Calendar module; all alerts render only through the Notifications module). Before adding any feature, check whether an existing module already owns that responsibility; extend it there or surface it as a read-only widget — never rebuild it. +5. **Tablet-first, touch-first**, with full parity and a well-adapted layout for desktop/PC. This is the primary device class for teachers and students in-classroom. +6. **Everything is dynamic and role/permission-aware**, driven by real DB relationships (school → year → class → subject → student → user), never assumed. +7. **Fully compliant with EU and German data protection law** (GDPR/DSGVO, BDSG, and the relevant state's Schulgesetz/Schul-Datenschutzverordnung), with explicit, engineered protections for student rights, teacher rights and staff co-determination (Personalrat/unions), parent rights, and the school's own controller obligations — detailed fully in **Module L** (§11). This is not a documentation afterthought: compliance status is itself DB-driven state that gates feature availability (§11.9). +8. Recommended open-source license: **AGPLv3** (prevents a third party from taking this free/community project and reselling it closed-source without contributing back) — team to confirm. + +> **Hard disclaimer to carry into the product and into `docs/`:** This PRD gives the *engineering* requirements needed to make legal compliance *possible*. It is not legal advice. Every school deploying this platform **must** involve its own **Datenschutzbeauftragter (DPO)**, its **Schulträger**, and — for co-determination matters — its **Personalrat/Lehrerrat** before go-live (see §11.9). + +--- + +## 1. Reference Product Analysis (functional inspiration only, not a visual/brand copy) + +**Digidoo** (digidoo.com) is a German/Austrian EdTech SaaS. Do **not** copy their branding, illustrations, exact wording, logo, or any copyrighted text/images — build an original UI/UX and original copywriting, only inspired by the **feature set and workflow concept**. Core concepts we reproduce functionally in **Module A** (§4): + +- **Competency grids ("Kompetenzraster")**: pre-built and customizable sets of subject/grade-level competencies teachers can use as templates or fully customize. +- **Learning progress entries**: teachers log individual observations per student, linked to one or more competencies, over time (a running log, not a snapshot). +- **Performance assessments ("Leistungsfeststellung")**: structured tests/assignments/checks linked to competencies, feeding the same student timeline. +- **Competence Flower ("Kompetenzblume")**: a radar/spider chart per student showing strength per competency category. +- **One-click reports**: automatically generated, competency-based report text and full report-card documents (PDF). +- **Grade management ("Notenverwaltung")**: configurable weighting between learning-progress entries and formal assessments to compute a transparent final grade. +- **Free tier that "just works"**: since this whole project is free, the entire app is simply free — no tiers. + +We explicitly **exclude** anything tied to Digidoo's commercial SaaS business (billing/subscriptions, marketing site, SSO/enterprise integrations like WebUntis) as out of MVP scope. + +--- + +## 2. Module Map + +The platform is organized into cohesive modules, each owning its data and UI completely (principle #4 above). + +| # | Module | Owns | +|---|--------|------| +| A | **Academics Core** | Competency grids, learning-progress entries, assessments, competence flower, grading, report cards | +| B | **Signage** | TV/billboard displays: announcement slides, sponsor slides, emergency/fire/counseling alerts | +| C | **Communication** | Direct messaging, escalation rules, class/grade social rooms, class-hours messaging locks | +| D | **Learning Hub** | Videos, interactive exercises, worksheets, vocab trainer, learning game, AI-assisted content authoring | +| E | **AI Tutor** | In-context homework help, hint-only policy, full lockout during tests, content translation for foreign students | +| F | **Exam Mode** | Kiosk/lockdown test-taking, teacher live controls, student warning system, incident log | +| G | **Digital Notebook** | Typed + stylus/pen notebook per student per subject, teacher pen annotation on tests/homework | +| H | **Notifications** | All push/in-app notifications — the *only* place alerts render outside their originating context banner | +| I | **Calendar** | All dates/deadlines/appointments/exam schedules/report deadlines — the *only* source of truth for "when" | +| J | **Dashboard & Widgets** | Per-user customizable home screen assembled from read-only widgets pulling from A–I | +| K | **Identity & Admin** | Users, roles, schools, classes, disciplinary records, audit log, permission/rule configuration | +| L | **Legal & Data Protection** | GDPR/DSGVO compliance engine, retention policy, DPIA gating, RoPA, rights fulfillment for all parties | + +--- + +## 3. Tech Stack + +- **Frontend**: React (or Next.js in a single full-stack app) + TypeScript. UI: Tailwind CSS + shadcn/ui. Charting: Recharts or Chart.js (radar chart for the Competence Flower). +- **Backend**: Next.js (App Router) full-stack, keeping frontend + backend in one deployable app — simplest for a small volunteer team. +- **Database**: **SQLite** via **Prisma ORM** (or Drizzle — pick one, justify). Reasoning: trivial hosting (single file, easy backups, ample for a single school's scale), migrations + type safety, and a documented upgrade path to Postgres if a school ever outgrows SQLite (avoid SQLite-only quirks in app logic to keep this portable). +- **Auth**: Self-hosted email/password (argon2id hashing) + session cookies (Lucia Auth / Auth.js credentials provider). No third-party paid auth. Optional TOTP 2FA. +- **PDF generation**: `@react-pdf/renderer` or Puppeteer-based HTML-to-PDF for report cards. +- **File storage**: Local filesystem (school-hosted), abstracted behind a storage interface so S3-compatible storage can be swapped in later. +- **Hosting target**: Single Docker container / docker-compose with a volume for the SQLite file + generated files, deployable on a small VPS. +- **i18n**: German and English UI strings from day one (`next-intl` or equivalent) — all UI text through translation keys, never hardcoded. +- **PWA/Kiosk infrastructure**: **Workbox** for the service worker — offline shell caching, reliable kiosk/fullscreen "install as app" behavior, and Web Push delivery (Modules F, H, §9). +- **Worker offloading**: **Comlink** to offload heavy client-side work (stylus stroke processing in the Digital Notebook, client-side AI-translation pre/post-processing) to a Web Worker via simple RPC, keeping the main thread responsive for touch input (§9). +- **AI/semantic search (optional)**: **Chroma**, an embeddable vector database, for semantic search over Learning Hub resources and retrieval-augmented context for the AI Tutor. Keep it behind an interface, not a hard dependency — a school without AI features never needs to run it. Core relational data always stays in SQLite (§0.1); Chroma, if used, stores only content embeddings for retrieval, never core school records (§9). +- **Testing**: Vitest/Jest (unit), Playwright (E2E for critical flows: login, create competency grid, log an entry, generate report, run an exam session). + +Deliver `docs/ARCHITECTURE.md` (stack rationale + self-hosting path) and `docs/SELF_HOSTING.md` (Docker deployment for a non-technical school IT admin). + +--- + +## 4. Module A — Academics Core + +### 4.1 Entities + +**Organizational:** +- **School** — id, name, schoolType (elementary/middle/gymnasium/other), country, timezone, createdAt. +- **User** — id, schoolId (nullable), email, passwordHash, firstName, lastName, role, locale, twoFactorSecret (nullable), createdAt, deletedAt (soft delete). +- **SchoolYear** — id, schoolId, label, startDate, endDate. +- **ClassGroup** — id, schoolId, schoolYearId, name, gradeLevel, schoolType. +- **ClassGroupTeacher** (M:N) — classGroupId, userId, role (HOMEROOM_TEACHER, SUBJECT_TEACHER). +- **Student** — id, schoolId, firstName, lastName, dateOfBirth (nullable, minimize what's collected), externalId (nullable, import matching), createdAt, deletedAt. +- **Enrollment** — id, studentId, classGroupId, schoolYearId, startDate, endDate (nullable = still enrolled) — models students moving between classes/schools over time. + +**Subject & competency structure:** +- **Subject** — id, schoolId (nullable = global template), name, gradeLevelMin/Max. +- **CompetencyTemplate** — id, name, description, subjectId (nullable), schoolType, gradeLevelMin/Max, isGlobalTemplate, createdByUserId (nullable for system templates), version. +- **CompetencyCategory** — id, competencyTemplateId, name, order (groups competencies into "petals" of the flower — e.g. Reading/Writing/Listening within Language Arts). +- **Competency** — id, categoryId, code, title, description, order, masteryLevels (related table). +- **MasteryLevelDefinition** — id, competencyId (or categoryId if shared), levelValue, label, description. +- **ClassCompetencyAssignment** — links a ClassGroup+Subject to a chosen CompetencyTemplate for a SchoolYear, plus per-school customizations. **Clone-on-customize**: assigning a template creates an editable school-owned copy so edits never mutate the global template. + +**Progress & assessment:** +- **LearningProgressEntry** — id, studentId, competencyId, teacherId, classGroupId, date, masteryLevelValue, note (optional), createdAt — append-only observation log. +- **Assessment** ("Leistungsfeststellung") — id, classGroupId, subjectId, teacherId, title, date, type (TEST/ORAL/PROJECT/HOMEWORK/OTHER), maxScore (nullable), weight, **deliveryMode** (enum incl. `DIGITAL_LOCKED`, which triggers Module F's kiosk flow). +- **AssessmentCompetencyLink** (M:N) — assessmentId, competencyId, weight. +- **AssessmentResult** — id, assessmentId, studentId, score (nullable), masteryLevelValue (nullable), note. + +**Grading:** +- **GradingScheme** — id, classGroupId or subjectId, name, type (NUMERIC_GRADE/VERBAL_FEEDBACK/COMBINED), scaleDefinition (JSON, configurable per country/state). +- **GradingWeightRule** — id, gradingSchemeId, sourceType (LEARNING_PROGRESS/ASSESSMENT), targetRef, weightPercent. +- **ComputedGrade** — id, studentId, subjectId, classGroupId, schoolYearId, period, computedValue, computedAt, isFinalized, **finalizedByUserId (required, human — see §11.5)**, overriddenValue (nullable), overrideReason. + +**Reporting:** +- **Report** — id, studentId, classGroupId, schoolYearId, period, generatedByUserId, generatedAt, status (DRAFT/FINAL), pdfFilePath, includesGrades, optional appendix of Digital Notebook entries for the period (teacher-selectable, off by default). +- **ReportSection** — id, reportId, competencyCategoryId (nullable), generatedText, order. + +**Auditing:** +- **AuditLog** — id, userId, action, entityType, entityId, timestamp, metadata (JSON). +- **DataExportRequest** — id, requestedByUserId, scope, status, filePath, requestedAt, completedAt. + +**Design requirements:** UUID/ULID primary keys (not SQLite-only autoincrement, for portability). All timestamps UTC. Soft deletes on Student/User with an explicit, audit-logged "permanently erase" flow, never a silent hard delete. SQLite WAL mode enabled; document backup strategy (copy the `.sqlite` file + files directory; consider `litestream`). Seed script (`prisma/seed.ts`) creates a demo school, teacher login, 2 classes, ~15 students, one sample competency template (Math + German, elementary, 2 categories, ~6 competencies each, 4-point scale), sample entries and one assessment — app explorable immediately after `docker-compose up`. + +### 4.2 Roles for this module +Teacher, School Admin, Super Admin — see full role model in §10 (Module K), which extends this with Counselor, Parent, Student, and DPO. + +### 4.3 Core user stories + +- **Onboarding & auth:** register/login/reset with argon2id-hashed passwords, optional TOTP 2FA, rate-limited login, CSRF protection on mutating routes. +- **School/class/student setup:** create classes, set grade level/school type, add students (manual or CSV import with preview + error list + re-run). +- **Competency grid management:** browse starter templates filtered by school type/grade/subject; assign (clone) to a class+subject; edit the school's copy or build custom. +- **Logging learning progress:** fast entry from a class roster (<3 interactions), full student timeline, entries editable by the original author within a configurable window, all edits audit-logged. +- **Assessments:** create, link to competencies with weights, bulk spreadsheet-like results entry for a whole class. +- **Competence Flower:** per-student radar chart, aggregation method configurable per template (latest value / average of last N / weighted by recency), hover/tap shows underlying entries, exportable as PNG/SVG. +- **Grade computation:** transparent weighted breakdown, manual override with required justification (audit-logged), computation is a pure deterministic function with unit tests covering edge cases (missing data, all-verbal, partial-year enrollment). +- **Reports:** draft → review/edit → finalize (locked + versioned) → PDF, single or bulk-class export, phrase-template system (data-driven, not hardcoded) for narrative text per mastery level. +- **Data portability & privacy:** export all class/school data as JSON/CSV; permanent erasure with confirmation + audit trail, cascading correctly including generated PDFs on disk (this flow is unified with Module L's rights fulfillment, §11.5). +- **Dashboard:** classes, recent activity, students without a recent entry (follow-up flag), upcoming report deadlines — surfaced as widgets in Module J, not a separate dashboard implementation. + +--- + +## 5. Module B — Signage (TV / Billboards) + +Replaces physical bulletin boards with school-controlled TV displays for announcements, class/period info, sponsor content, and emergency alerts. + +**Roles:** Admin/Management authors & publishes; sponsor content is admin-approved only (no self-serve); teachers can request a slide (goes to an approval queue by default, config-togglable to trusted self-publish for INFO-type slides). + +**Entities:** +- **SignageDisplay** — id, schoolId, location/name, resolution profile, isActive. +- **SignageSlide** — id, schoolId, type (ANNOUNCEMENT/SPONSOR/INFO/SCHEDULE), title, body/media, priority, startAt, endAt, createdByUserId, approvalStatus. +- **SignagePlaylist** — id, displayId (or all displays), ordered slide list, rotation duration per slide. +- **EmergencyAlert** — id, schoolId, type (FIRE/LOCKDOWN/MEDICAL/GENERAL_EMERGENCY/COUNSELING_NOTICE), message, triggeredByUserId, triggeredAt, resolvedAt, affectedScope. + +**Core flows:** +- Normal rotation cycles active, date-windowed slides weighted by priority. +- **Emergency override (highest priority, non-negotiable):** any `EmergencyAlert` immediately interrupts every display's rotation with a full-screen, type-styled alert, and simultaneously fires a push notification (Module H) to staff, and for FIRE/LOCKDOWN to logged-in students/parents per school policy. Clears only on explicit resolution by an authorized user, logged in AuditLog. +- **Counseling notice**: lower-urgency, staff-facing only variant — not broadcast school-wide, avoids exposing a student's need for support (also see §11.3). +- Sponsor slides: admin-uploaded, date-windowed, weighted rotation, never auto-published from a teacher request. + +--- + +## 6. Module C — Communication (Messaging & Escalation) + +The escalation logic is implemented as **data-driven policy rows**, never hardcoded if/else chains. + +**Entities:** +- **MessageThread** — id, schoolId, type (DIRECT/CLASS_ANNOUNCEMENT/SOCIAL_ROOM), participantsOrScope. +- **Message** — id, threadId, senderId, body, sentAt, readReceipts. +- **EscalationPolicy** — id, schoolId, fromRole, toRole, requiredPriorContactRole, waitingPeriodBusinessDays (default 3), isActive, overriddenByUserId (nullable). **All numbers here are read from this table, never hardcoded.** +- **EscalationRequest** — id, requesterId, subject, firstContactThreadId, firstContactAt, eligibleToEscalateAt (computed using the school calendar to skip holidays — Module I), status (WAITING/ELIGIBLE/ESCALATED/RESOLVED). +- **SocialRoom** — id, schoolId, scope (CLASS/GRADE_LEVEL/CUSTOM_GROUP), name, moderatorUserIds, isActive. +- **ClassMessagingLock** — id, classGroupId, lockedByUserId, lockedFrom, lockedUntil (nullable = "until class ends," period-aware via Module I). +- **DisciplinaryRecord** — id, userId, type, description, issuedByUserId, linkedIncident (nullable FK to an ExamWarning or reported message), visibleToParent (bool, enforced access control, not just UI — §11.5), createdAt. + +**Core rules (default `EscalationPolicy` rows, admin-editable):** +- **Parent → Admin:** only for genuinely necessary matters, and only after messaging the student's homeroom teacher first. If no reply within **3 business days**, the parent gains the right to escalate to Admin on that thread. +- **Student → Admin/Counseling:** same pattern — homeroom teacher first, then counseling/admin after 3 business days without reply. +- **Teacher → Admin:** direct, no escalation gate. +- **Admin override:** can loosen, tighten, or remove any `EscalationPolicy` row, or grant a case-by-case exception (logged with reason). Can issue a `DisciplinaryRecord` against a teacher or student from a messaging or exam-incident context. +- **Social rooms:** class- or grade/"Stufe"-scoped group discussion spaces, moderated, config-controlled per school (which scopes enabled, moderation-before-visible or not). +- **Class-hours lock:** teacher toggles `ClassMessagingLock` for their class; students blocked from student↔student and student↔staff messaging until lifted (auto-lift at period end via Module I, or manual — config toggle) — students still receive urgent Signage/Notification-level alerts. +- **True-emergency bypass:** the escalation queue always has a parallel "urgent safety concern" path that skips the 3-business-day gate entirely (§11.7) — the escalation rule is a workflow-efficiency rule, never a barrier to reporting a genuine safety issue. + +Business-day computation reads the school's holiday/weekend calendar from Module I, never assumes fixed Mon–Fri. + +--- + +## 7. Module D — Learning Hub + +A curriculum-aligned self-study resource center, functionally inspired by tools like Sofatutor — original or licensed/open content only, organized the same *way* (by subject/grade), never copying branding, exact video content, or exact wording. + +**Entities:** +- **LearningResource** — id, schoolId (nullable = shared library), subjectId, gradeLevel, type (VIDEO/INTERACTIVE_EXERCISE/WORKSHEET/VOCAB_SET/GAME_MODULE), title, description, contentRef, competencyLinks (M:N to `Competency`, so resources surface contextually — e.g. from a weak competency-flower petal), createdByUserId, reviewStatus (DRAFT/PENDING_REVIEW/PUBLISHED), aiGenerated (bool). +- **WorksheetSolution** — attached to a WORKSHEET resource, hidden by default, revealable by student after attempt or by teacher setting. +- **VocabSet / VocabItem** — vocab lists feeding a spaced-repetition vocab trainer. +- **GameProgress** — per-student progress/score for the learning game, scoped by subject. + +**Core flows:** teachers/admin create/edit or request AI-assisted drafting (always lands `PENDING_REVIEW`, never auto-published — human review required). Resources are discoverable by subject/grade browse, search, and contextually (surfaced next to a weak Competence Flower petal and in AI Tutor suggestions). Printable worksheets export as PDF with a teacher toggle to include/exclude the solutions page. + +--- + +## 8. Module E — AI Tutor & AI Content Assistance + +**Hard policy — enforced server-side, not just prompt instructions:** + +- **May**: explain a concept, walk through a *similar* worked example, give a hint, define a term, nudge toward the next step on a specific question. +- **Must never**: produce a direct final answer to an assigned homework/assessment question, complete an assignment for the student, or operate at all while any `Assessment` with `deliveryMode = DIGITAL_LOCKED` is active for that student — the AI chat endpoint independently checks "is this student currently inside an active exam session?" server-side and refuses if so, regardless of client-side UI state (ties into Module F's defense-in-depth, §8). +- Every interaction is logged (`AiTutorInteraction`: id, studentId, subjectId/competencyId, promptSummary, responseSummary, timestamp, flaggedAsHomeworkAttempt) for teacher/admin audit — defaulting to **summary-only, pseudonymized** retention per §11.2/§11.3, with a school-configurable toggle for full-transcript retention and an explicit retention period from Module L. +- **Translation:** on-demand AI translation of learning content (and optionally UI copy) for foreign-language students, labeled "AI-translated," with a flag-for-review path for bad translations. Home-language inference from translation requests is treated with the same minimization discipline as any other data point (§11.3) — no persistent linguistic/ethnic profile is built. +- **Content-authoring assist** (bridges Module D): the same AI capability drafts Learning Hub resources or Signage copy, always landing in a review/draft state. + +Module E must never ship ahead of Module F's server-side test-time gate (§13, phasing). + +--- + +## 9. Module F — Exam Mode (Digital Lockdown & Proctoring) + +**Entities:** +- **ExamSession** — id, assessmentId, studentId, deviceId, startedAt, status (ACTIVE/PAUSED/RESUMED/ENDED/CANCELLED), pausedByUserId (nullable), pauseReason (BATHROOM_REQUEST/TEACHER_INITIATED/STUDENT_DISTRESS/THIRD_WARNING/OTHER). +- **ExamWarning** — id, examSessionId, issuedByUserId, reason, sequenceNumber, issuedAt. +- **ExamIncidentEvent** — id, examSessionId, type (LOCK_BREACH_ATTEMPT/BATHROOM_REQUESTED/BATHROOM_GRANTED/DISTRESS_SIGNAL/PAUSED/RESUMED/CANCELLED), metadata, timestamp. + +**Core flow:** +1. Opening a `DIGITAL_LOCKED` assessment enters **kiosk/fullscreen lockdown** — device locks to the exam UI only, no other tabs/apps, no AI Tutor access (hard-gated), timer visible per config. +2. **Bathroom request:** student taps "request break" → real-time alert to teacher (Module H) → teacher approval pauses that individual `ExamSession` only → resumes on return. +3. **Distress signal:** student sends "I feel unwell / need help" at any time → immediate high-priority alert, distinct styling/sound from a routine request → teacher attends; unresolved → teacher pauses or cancels that student's exam (reason required, logged, visible to parent/admin). **This is health-adjacent data — store only a minimal event flag plus optional free text, never a structured symptom/diagnosis field, restricted to need-to-know visibility (§11.3).** +4. **Behavior warnings:** teacher issues an `ExamWarning` for talking/looking around, etc. 1st and 2nd logged and shown to the student (2nd explicitly "final warning"); 3rd **auto-pauses** that student's exam — but the **pause itself** is a safety/integrity mechanism, not an Art. 22 "decision" about the student; any resulting **disciplinary consequence** requires a human to actually create the linked `DisciplinaryRecord` — the system never auto-generates one (§11.5). +5. Teacher gets a live **class exam-monitor view**: every student's session status at a glance, one-tap pause/resume/cancel/warn. +6. Lockdown enforcement is **defense-in-depth**: client-side kiosk/fullscreen API + Workbox service worker to intercept navigation attempts, **and** an independent server-side session flag blocking AI Tutor/Learning Hub/Messaging calls for the duration — never trust the client alone for an integrity feature. + +Module F is the highest integrity/device-sensitivity module — see phasing (§13): built last, after the touch/kiosk foundation (§9 tech stack) is mature. + +--- + +## 10. Module G — Digital Notebook + +Digital replacement for the paper exercise notebook (explicit environmental motivation), supporting Germany's range of notebook/exercise-book conventions. + +**Entities:** +- **NotebookTemplate** — id, schoolId (nullable = global), name, ruling type (lined/squared-Kariert/blank), gradeLevel/subject applicability. +- **NotebookPage** — id, studentId, subjectId, classGroupId, templateId, createdAt, content (typed text blocks + vector "ink" strokes stored as SVG/stroke-JSON, not raster, so it stays editable/searchable/annotatable). +- **NotebookAnnotation** — id, pageId (or linked Assessment submission), authorUserId, strokeData/comment, createdAt — non-destructive teacher correction marks, captured as its own layer so original student work is never overwritten. + +**Core flows:** students write via keyboard and/or stylus, both coexisting on a page. Teachers annotate directly on a digital test/notebook page with pen input (highlighting, circling, margin comments), mirroring paper red-pen correction. Pages organized per subject per student per school year, browsable page-by-page/date-by-date, exportable as PDF. + +--- + +## 11. Cross-Cutting Modules H–K, and Module L (Legal & Data Protection) + +### 11.1 Module H — Notifications (single source of alerts) +Every push/in-app alert — messaging, exam warnings/bathroom requests, signage emergencies, escalation-eligibility notices, learning-hub review-queue items — routes through **one** `Notification` entity and **one** delivery pipeline (Web Push API via Workbox service worker + an in-app notification center). No module renders its own separate toast/badge system; every other module *emits into* Module H (per §0.4). + +`Notification` — id, recipientUserId, type, priority (NORMAL/URGENT/EMERGENCY), title, body, deepLinkRef, createdAt, readAt, deliveredChannels (JSON). + +### 11.2 Module I — Calendar (single source of "when") +All dates live here: class timetable/periods, exam schedules, report-card deadlines, school holiday calendar (feeds Module C's business-day math), signage slide date windows (read-only reflection of `SignageSlide.startAt/endAt`, not a duplicate field). Same non-duplication logic applies to every other module with a date. + +### 11.3 Module J — Dashboard & Widgets +Every role gets a customizable home dashboard: add/remove/reorder widgets from an approved catalog, each a thin read-only view into one owning module. Layout persists per user (`UserDashboardLayout` — userId, widgetInstances JSON: widgetType, position, size, config). Widgets never contain business logic beyond fetch/render — structurally enforcing §0.4. + +### 11.4 Module K — Identity, Roles & Admin +Roles: **Teacher**, **School Admin**, **Super Admin** (project/instance maintainer), **Counselor** ("Rehberlik" — receives escalated threads per Module C), **Parent** (read-only into their child's reports/grades/flower/visible disciplinary records, messaging per Module C), **Student** (Learning Hub, gated AI Tutor, Exam Mode, Digital Notebook, gated Communication), and **DPO** (Module L, §11.8 — compliance-surface read access + gate-approval power over high-risk modules). Sponsor is not a login role — sponsor content is admin-managed only. All escalation thresholds, warning limits, kiosk toggles, and messaging rules are exposed in a single **Admin Policy Console** so a non-technical admin can tune the entire rules engine without a code change. + +### 11.5 Module L — Legal, Data Protection & Rights Compliance (EU/DE) + +This module governs every other module. It layers **EU GDPR (DSGVO)**, the **German Federal Data Protection Act (BDSG)**, and the **state-level School Acts (Landesschulgesetze) and School Data Protection Ordinances**, which vary across all 16 Bundesländer and often override/supplement the GDPR baseline for public schools — plus **Landespersonalvertretungsgesetz (LPVG)/BPersVG** for staff co-determination, and general child-rights principles (KJSG/UN-CRC). + +**Data protection by design & default (Art. 25), applied to every module:** +- Data minimization at schema level — every optional field defaults to *not collected* (e.g., `Student.dateOfBirth` stays nullable, populated only if state rules actually require it). +- Purpose limitation — each table documents its legal basis/purpose in `docs/DATA_MODEL.md` (e.g., `ExamWarning` → exam integrity & pedagogical record; legal basis: Schulgesetz duty of fair assessment). +- Pseudonymization where feasible (e.g., `AiTutorInteraction` stores a pseudonymous reference by default). +- Storage limitation — every personal-data entity has a `retentionPolicyRef` into `DataRetentionPolicy` (below), swept by a scheduled job, never a manual "someone remembers" process. +- Access control defaults to deny — a Teacher's access to a student is scoped to classes currently taught; losing that assignment auto-narrows access. + +**Special-category / sensitive data (Art. 9) — explicit flags at every touchpoint:** +- The Exam Mode **distress signal** (§8) is health-adjacent: minimal event flag only, restricted visibility, short default retention unless it escalates into a formal record. +- `DisciplinaryRecord` — highly sensitive minor data even if not technically Art. 9; same need-to-know visibility model; `visibleToParent` is a real enforced access-control flag, never just a UI toggle. +- Signage **counseling notices** (§5) never broadcast identifying detail school-wide. +- No field anywhere in the schema for race/ethnicity/religion/sexual orientation/union membership/political opinion — explicitly out of scope, permanently. + +**Teacher rights & staff co-determination (Personalrat/unions):** +- Any feature capable of monitoring staff behavior or performance (`AuditLog` on teacher actions, message read-receipts, exam-monitor response-time visibility, content-approval analytics, etc.) **triggers Personalrat co-determination rights** under the relevant state LPVG (or BPersVG) — analogous to § 87(1) No. 6 BetrVG in the private sector. +- Before shipping to a real school, each such feature needs a **Dienstvereinbarung-ready spec sheet** (`docs/personalrat/.md`): what's logged, who sees it, retention, and an explicit statement it won't be used for unilateral performance evaluation without Personalrat agreement. +- Recommend looping in **GEW/VBE** and the Personalrat during rollout — surfaced as a required onboarding checklist item (§11.9) before enabling any monitoring-capable module. +- Teachers get the same self-service data-export as students (reusing the one pipeline, §0.4), and a per-teacher "quiet hours" setting for non-urgent notifications (urgent/emergency alerts always bypass this by design). + +**Student rights:** +- **Access (Art. 15):** full export via the one Module K export pipeline — competency entries, grades, notebook pages, messages, disciplinary records, AI Tutor interaction summaries. +- **Rectification (Art. 16):** via existing audit-logged edit flows in Module A. +- **Erasure (Art. 17), bounded by retention duties:** account/active data erasable on request or on leaving school, except records under an active `DataRetentionPolicy`; erasure response reports what was retained and why. +- **No solely-automated decisions with legal/significant effect (Art. 22):** `ComputedGrade.isFinalized` cannot become `true` without a `finalizedByUserId` (a human). Exam Mode's third-strike **pause** may be automatic (safety mechanism), but any resulting `DisciplinaryRecord` requires a human actor — never auto-generated. +- **No repurposing into a broader "risk score"** or similar — explicitly a forbidden feature (not just unbuilt), documented in `docs/ROADMAP.md`'s "will not build" section. + +**Retention periods — config-driven (extends §0.2):** +- New entity **DataRetentionPolicy** — id, schoolId, dataCategory (LEARNING_PROGRESS/ASSESSMENT_RESULT/FINAL_REPORT/DISCIPLINARY_RECORD/EXAM_INCIDENT/MESSAGE/AI_TUTOR_LOG/NOTEBOOK_PAGE/SIGNAGE_LOG), retentionPeriodMonths, legalBasisNote (free text, filled by the school's DPO), reviewedByUserId, reviewedAt. +- Ship **empty of defaults** for legally-mandated categories (forces the DPO to enter their state's actual correct numbers, never a guessed default); ship **modest editable defaults** for non-mandated, product-only categories (e.g., AI Tutor log summaries ~12 months). + +**Parent rights:** +- **Information duty (Art. 13/14):** plain-language privacy notice generated from the same module documentation/retention config, so it can never drift from what the system actually does. +- **Consultation/approval before rollout:** most state School Acts require **Schulkonferenz**/**Elternbeirat** consultation before new pupil-data software goes live — a literal onboarding checklist step (§11.9); no silent activation of high-impact modules (Signage emergency broadcast, Exam Mode, AI Tutor) without acknowledging this happened. +- **Access to their child's records:** grades, reports, competence flower, `visibleToParent`-flagged disciplinary entries — never paywalled or admin-discretionary in a way that would violate this right. +- **Messaging rights:** the escalation rule (§6) is a workflow-efficiency rule, never a barrier to a genuine safety concern — a parallel urgent path always bypasses the gate. + +**School's rights & obligations (controller responsibilities):** +- **Controller vs. processor:** the school (via its Schulträger) is normally the data controller; a self-hosted instance run by the school itself simplifies Art. 28 needs, while a third-party-operated instance requires a signed **Art. 28 AVV** — ship a starting-draft template in `docs/legal/avv-template.md`, explicitly marked for the DPO/lawyer to finalize. +- **Records of Processing Activities (Art. 30):** `docs/legal/verzeichnis-verarbeitungstaetigkeiten.md`, generated mechanically from each module's purpose+legal-basis notes. +- **DPIA (Art. 35):** the platform plausibly meets mandatory-DPIA criteria (large-scale children's-data processing, systematic monitoring via Exam Mode, health-adjacent signals). **Modules E (AI Tutor) and F (Exam Mode) stay disabled in the Admin Policy Console until a `DpiaRecord`** (id, schoolId, moduleScope, completedAt, approvedByUserId, documentRef) exists. +- **DPO role** (Module K extension): read access to RoPA, retention policy table, DPIA records, and school-wide audit logs, plus gate-approval power over high-risk modules. + +**Product-level compliance onboarding gate** — implemented as a required flow for every new school instance before real student data is usable: +1. Enter Schulträger/DPO contact details. +2. Select the Bundesland → auto-loads that state's known retention-period *prompts* (labels only, never assumed numbers) into `DataRetentionPolicy` for confirmation. +3. Log Elternbeirat/Schulkonferenz consultation status per module before it can go live for real use. +4. Log Personalrat consultation status for every monitoring-capable module. +5. Complete/upload the DPIA before Modules E and F unlock. +6. Only then does the Admin Policy Console allow those modules to leave "disabled" state. + +This makes compliance a DB-driven, enforced product flow, consistent with §0.2/§0.6 — never a one-time audit forgotten after launch. + +**On the `Klotzkette/claude-fuer-deutsches-recht` reference repository:** a large, community-maintained collection of Claude Code skills/prompts for German legal practice (labor, corporate, insolvency, data-protection, procedural law, plus Schulrecht/Hochschulrecht clusters), explicitly published as an experimental legal-drafting aid, not a certified compliance product. **Bounded recommended use:** consult its `datenschutzrecht` skill as a drafting assistant when preparing the AVV template, the RoPA structure, and the parent-facing privacy notice — a faster, correctly-structured first draft. **Never** treat its output (or this PRD's, or any AI's) as a substitute for sign-off by the school's actual DPO or legal counsel — every generated `docs/legal/*.md` file should carry that disclaimer as a literal header. It is a prompt/skill library, not a runtime dependency — keep it entirely out of the app's dependency tree. + +--- + +## 12. Touch/Tablet UI-UX & AI Infrastructure Notes + +- Minimum touch target 44×44pt, larger on primary actions (exam controls, entry-logging buttons). +- Enlarged cards/list rows at tablet breakpoint, denser layout at desktop breakpoint — one responsive design system, not two codebases. +- **Kiosk/fullscreen mode:** Fullscreen API + Workbox service worker (offline shell caching, reliable PWA install, push delivery even when the tab isn't focused). +- Free browser push notifications via the Web Push API — no paid vendor required. +- Full keyboard navigation and screen-reader labels (WCAG 2.1 AA target), extended to Signage (legible from a distance) and Exam Mode (usable with assistive tech even inside lockdown). +- Original visual design — no reuse of Digidoo's illustrations, palette, or copy; a small design-token set (colors, spacing, typography), warm and education-appropriate, distinct accent color. +- Mobile/tablet-first specifically for the entry-logging flow; desktop-first is fine for admin/setup and reports. +- German and English complete from day one; default German (DACH audience), English complete since this is an open project others may adopt. +- Empty states and an onboarding checklist for a brand-new school ("1. Create your first class → 2. Assign a competency grid → 3. Add students → 4. Log your first entry"). + +--- + +## 13. Non-Functional Requirements + +- **Self-hostability:** one `docker-compose.yml`, one `.env.example`, a `README.md` a non-expert school IT person can follow — target under 10 commands from `git clone` to a running app. +- **Performance:** SQLite + server-rendered pages where sensible; paginate long lists (rosters, entry timelines). +- **Backups:** documented, scripted daily backup (cron copying the SQLite file + files directory); consider `litestream`; document SQLite's concurrent-write limits and when a school should be advised to migrate to Postgres. +- **Security:** Zod (or equivalent) input validation at every API boundary, parameterized queries only, secure cookie flags, rate limiting on auth endpoints, dependency vulnerability scanning in CI. Exam Mode integrity is defense-in-depth (§9.6). Emergency Alerts (§5) need the lowest possible latency path, treated as fire-drill-grade reliability, with explicit failure/retry testing. +- **Data minimization:** AI Tutor logging defaults to summary-only (§8); Digital Notebook stroke data is exportable/erasable like any other student record (§11.5). +- **Accessibility:** WCAG 2.1 AA across all modules. +- **CI/CD:** GitHub Actions running lint, typecheck, unit tests, E2E smoke tests on every PR. + +--- + +## 14. Project Structure (suggested) + +``` +/app # Next.js App Router pages + /(auth) + /(dashboard) + /api +/components +/lib + /db # Prisma/Drizzle client, schema + /grading # pure grading calculation functions + tests + /reports # report generation + PDF rendering + /permissions # authorization helpers + /compliance # retention sweeps, DPIA gate checks, RoPA generation +/prisma (or /drizzle) + schema.prisma + seed.ts + migrations/ +/docs + ARCHITECTURE.md + SELF_HOSTING.md + DATA_MODEL.md # ER diagram + entity descriptions + purpose/legal-basis notes + CONTRIBUTING.md + ROADMAP.md # incl. explicit "will not build" section + /legal + avv-template.md + verzeichnis-verarbeitungstaetigkeiten.md + /personalrat + .md # co-determination spec sheets per monitoring-capable module +/tests + unit/ + e2e/ +docker-compose.yml +.env.example +README.md +LICENSE +``` + +--- + +## 15. Revised MVP Phasing + +Given the scope, ship in **stages**, each fully functional and demo-able: + +**Stage 1 — Foundation:** Identity/roles/schools/classes/students, Module A (Academics Core) end-to-end, Calendar (Module I) as the shared date backbone, Notifications (Module H) skeleton, Dashboard/Widgets (Module J, 2–3 widgets), and the **compliance foundation**: `DataRetentionPolicy` table, `docs/DATA_MODEL.md` purpose/legal-basis notes, and the onboarding gate skeleton (§11.9 steps 1–2). + +**Stage 2 — Communication & Signage:** Module C in full (messaging, escalation policy engine, social rooms, class lock, the urgent-safety bypass path), Module B (signage, including Emergency Alert override), wired into Module H/I. Add onboarding gate steps 3–4 (Elternbeirat/Schulkonferenz and Personalrat consultation logging) since Module C introduces monitoring-adjacent surfaces (read receipts, audit trail). + +**Stage 3 — Learning & AI:** Module D (Learning Hub, manual authoring first, AI-assist fast-follow), Module E (AI Tutor with hard test-time gating from day one — never ship before Module F's gate exists). DPIA gate (§11.9 step 5) must be functional before Module E can be enabled for real use. + +**Stage 4 — Exam Mode & Digital Notebook:** Module F (kiosk lockdown, warnings, monitor view) and Module G (typed + stylus notebook, teacher annotation) — highest-integrity/most device-sensitive modules, saved for last so the touch/kiosk foundation is mature first, and gated behind the completed DPIA per §11.9. + +Each stage gets its own seed data, test-suite additions, and `docs/ROADMAP.md` entry. + +--- + +## 16. Definition of Done (MVP + full platform) + +**Core functionality (per module, §4–10):** +1. Auth (register/login/reset, optional 2FA) end-to-end. +2. School → SchoolYear → ClassGroup → Student → Enrollment CRUD with CSV import. +3. At least 2 seeded starter competency templates with categories, competencies, and a mastery scale. +4. Template assign/clone + school-copy editing. +5. Fast entry-logging UI from a class roster. +6. Assessments with bulk grid entry. +7. Competence Flower radar chart, computed from real data, configurable aggregation. +8. Grading scheme + weight rules + computed grade with breakdown + manual override (human-finalized only, §11.5). +9. Report generation (draft → final → PDF), single and bulk export. +10. Data export (JSON/CSV) and erasure flow, audit-logged, unified across student/teacher/parent (§11.5). +11. i18n scaffolding, DE + EN complete for implemented screens. +12. Dockerized self-hosting + docs, seed script for demo data. +13. Signage with working Emergency Alert override. +14. Communication with the full escalation-policy engine and urgent-safety bypass. +15. Learning Hub with at least manual (non-AI) content authoring functional. +16. AI Tutor shipped only after Exam Mode's server-side gate exists and is tested. +17. Exam Mode with the full bathroom/distress/warning flow and defense-in-depth enforcement. +18. Digital Notebook with typed + stylus input and non-destructive teacher annotation. +19. Test suite: unit tests for grading logic, permissions, and the escalation-policy business-day calculator specifically (not just an overall coverage %); Playwright E2E covering register → create class → assign template → add students → log entries → generate report, plus a signage-emergency-override smoke test and an exam-session lifecycle smoke test. + +**Compliance (Module L, §11.5) — a school instance is not production-ready until:** +- [ ] `docs/DATA_MODEL.md` has a purpose + legal-basis note for every personal-data entity. +- [ ] `DataRetentionPolicy` rows exist (school-confirmed, not defaulted) for every legally-mandated category. +- [ ] `docs/legal/verzeichnis-verarbeitungstaetigkeiten.md` (RoPA) generated and reviewed. +- [ ] AVV signed (third-party-operated instance) or explicitly marked not-applicable (self-hosted-by-school). +- [ ] DPIA completed and on file before Modules E or F are enabled. +- [ ] Elternbeirat/Schulkonferenz consultation logged per module. +- [ ] Personalrat consultation logged for every monitoring-capable module. +- [ ] Student/parent/teacher self-service data-export flows tested end-to-end. +- [ ] Privacy notice generator produces an accurate, plain-language notice matching actual configured retention periods (no drift between docs and DB config). + +`README.md` should state the project's mission plainly: a free tool built for/with a school, donation-supported, and explain how to contribute. + +--- + +## 17. Phase 2 / Stretch Goals (out of scope for v1 — document in `docs/ROADMAP.md`) + +- SSO (OIDC) support for schools with existing identity providers. +- Optional Postgres deployment mode for larger multi-school instances. +- Native offline-first PWA mode for spotty classroom Wi-Fi. +- **Explicitly forbidden, not just deferred (§11.5):** any feature that repurposes competency/AI-Tutor/behavioral data into a cross-context "student risk score" or similar profiling construct without a wholly separate legal basis and DPIA. + +--- + +## 18. Instructions for the AI Coding Agent + +1. Produce `docs/ARCHITECTURE.md` and the **full** Prisma/Drizzle schema (all entities across §4–11) and get sign-off before writing app code — this schema is large enough that an early wrong decision is expensive. +2. Build the shared **Policy/Config layer** (backing the Admin Policy Console, §11.4, and `DataRetentionPolicy`, §11.5) **before** implementing Module C's escalation logic or Module F's warning thresholds — both must read from it, proving "no hardcoding" structurally, not just by discipline. +3. Build Module H (Notifications) and Module I (Calendar) early, in Stage 1, precisely because every later module depends on them — prevents each module from inventing its own notification/date logic. +4. When implementing Module F, write the **server-side session-gating check** before the kiosk UI — the security/integrity property must not depend on the UI shipping correctly. +5. Flag explicitly, in `docs/DATA_MODEL.md`, every place a rule/threshold/retention-period is read from a policy table, and every entity's purpose + legal basis (so a future compliance audit can confirm zero hardcoded business rules and a complete RoPA). +6. Never enable Module E or F for a school instance until the corresponding onboarding-gate step (§11.9) is satisfied — build this as an actual code-level check, not a documented process someone might skip. +7. Keep commits small and descriptive; maintain a CHANGELOG. After each feature, write its unit/E2E tests before moving to the next. +8. Flag any point where a competency/grading/retention policy varies by German Bundesland — expose it as configuration, never hardcode one state's rules, and note the assumption in `docs/DATA_MODEL.md`. +9. Remember throughout: **this is a free, non-commercial, donation-supported school project.** Prefer boring, dependency-light, self-hostable solutions over anything introducing a paid third-party service or vendor lock-in — and treat every compliance requirement in §11 as a hard constraint, not a stretch goal. \ No newline at end of file diff --git a/docs/README.md b/docs/README.md index c716ff9..831c2f7 100755 --- a/docs/README.md +++ b/docs/README.md @@ -138,7 +138,7 @@ schulos/ ## License -MIT License — see [LICENSE](./LICENSE) for details. +MIT License — see [LICENSE](../LICENSE) for details. ## Contributing diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..aec2052 --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,31 @@ +# Roadmap + +The execution roadmap is maintained in the root `TODO.md` checklist. This file +is the public summary of its delivery order. + +## In Progress + +- Delivery and security baseline: Docker, CI baseline, Argon2id, origin checks, + local storage, WAL, and verified online backups. + +## Next + +1. Complete account lifecycle security: reset tokens, verification, optional + TOTP, and auth-related end-to-end tests. +2. Build the school policy engine: configurable escalation, holidays, retention, + communication locks, quiet hours, and module gates. +3. Consolidate navigation into canonical module owners and tabbed pages; build + role-aware Bento dashboards without duplicate workflows. +4. Complete academic workflows: template cloning history, fast entry, grading + finalization attribution, versioned reports, real PDF and ZIP exports. + +## Later + +- Communication policy and notification delivery completion. +- Calendar, leave, substitution, and operations consolidation. +- Learning Hub governance, AI safety, and exam middleware. +- Digital notebook annotation and PWA support. +- Emergency signage, compliance automation, and full E2E/accessibility release + gates. + +See `TODO.md` for detailed checkboxes, acceptance criteria, and blockers. diff --git a/docs/SELF_HOSTING.md b/docs/SELF_HOSTING.md new file mode 100644 index 0000000..25a6d00 --- /dev/null +++ b/docs/SELF_HOSTING.md @@ -0,0 +1,95 @@ +# Self Hosting + +## Prerequisites + +- Docker Engine with Docker Compose plugin +- A DNS name and HTTPS reverse proxy for production use + +## Quick Start + +1. Copy `.env.example` to `.env`. +2. Set `NEXT_PUBLIC_APP_URL` to the public application URL. +3. Review storage and optional SMTP values. +4. Build and start the service with `docker compose up -d --build`. +5. Review logs with `docker compose logs -f schulos`. +6. Run Prisma migrations inside the service when a release includes migrations. +7. Seed demo data only in a non-production instance with `pnpm db:seed`. + +The service listens on port `3000` by default. Set `SCHULOS_PORT` in `.env` to +change the host port. + +## Request Origin Protection + +All mutation requests below `/api/` require a matching `Origin` or `Referer`. +Set `NEXT_PUBLIC_APP_URL` to the public HTTPS origin. For an approved second +origin, such as a separate admin host, provide comma-separated origins. Do not +include wildcard origins. + +## Backup + +Back up the complete persistent volume, including the database, uploads, +exports, and backups. For SQLite in WAL mode, preserve the main `.db` file and +its `-wal` and `-shm` companions or use SQLite's online backup command. + +Test restores into a separate disposable deployment before relying on a backup. +The in-app backup endpoint writes a school-scoped JSON file to `BACKUP_PATH` and +verifies its readability before a restore drill; it does not automatically +overwrite a live database. + +### WAL And Verified SQLite Backups + +Run the following commands from the deployment directory after setting the +production environment values: + +```bash +pnpm db:wal +pnpm db:backup +``` + +`db:wal` enables WAL mode, normal synchronous behavior, foreign-key checks, and +a checkpoint. `db:backup` uses SQLite's online `.backup` command, writes the +copy to `BACKUP_PATH`, and runs `PRAGMA integrity_check` on the result. A +successful command prints the backup file path, byte size, and `integrity: ok`. + +Inside Docker, run the same commands with: + +```bash +docker compose exec schulos pnpm db:wal +docker compose exec schulos pnpm db:backup +``` + +Do not restore onto the active school deployment. Copy the verified backup to a +separate maintenance environment, set its `DATABASE_URL` to the copied file, +and complete a login and data-read smoke test first. + +## Upgrade + +1. Take and verify a backup. +2. Pull the reviewed release. +3. Run `docker compose up -d --build`. +4. Run required Prisma migrations. +5. Confirm health, login, and critical role workflows. + +## Password Hash Migration + +New passwords use Argon2id. Existing bcrypt password hashes remain valid for +one successful login and are transparently upgraded to Argon2id at that time. +No bulk password reset is required for this migration. + +## Account Recovery + +Password reset requests create a one-time, SHA-256-hashed token with the TTL +configured by `PASSWORD_RESET_TTL_SECONDS`. The API always returns the same +success message whether or not an account exists. In development only, or when +`AUTH_SHOW_RESET_TOKEN=true`, the raw token is returned for local testing. + +By default, self-hosted registrations are marked verified immediately. Set +`AUTH_REQUIRE_EMAIL_VERIFICATION=true` only after integrating a reviewed email +verification delivery flow. TOTP and recovery-code enrollment remain a planned +security enhancement. + +## Compliance Notice + +This software can support privacy-compliant operations but is not legal advice. +The deploying school must involve its DPO, Schultrager, and Personalrat before +processing real student data. diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..1410a7c --- /dev/null +++ b/middleware.ts @@ -0,0 +1,14 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { isCsrfProtectedRequest, isTrustedRequestOrigin } from '@/lib/csrf'; + +export function middleware(request: NextRequest) { + if (isCsrfProtectedRequest(request) && !isTrustedRequestOrigin(request)) { + return NextResponse.json({ error: 'Invalid request origin' }, { status: 403 }); + } + + return NextResponse.next(); +} + +export const config = { + matcher: ['/api/:path*'], +}; diff --git a/package.json b/package.json index f5661e4..4ceb340 100755 --- a/package.json +++ b/package.json @@ -31,6 +31,8 @@ "db:seed": "bun prisma/seed.ts", "db:seed-demo": "bun prisma/seed-demo.ts", "db:setup-demo": "bun run db:push && bun run db:seed-demo", + "db:wal": "node scripts/sqlite-wal.mjs", + "db:backup": "node scripts/sqlite-backup.mjs", "test": "bun test", "typecheck": "next build --webpack && tsc --noEmit --pretty false" }, @@ -72,6 +74,7 @@ "@reactuses/core": "^6.0.5", "@tanstack/react-query": "^5.82.0", "@tanstack/react-table": "^8.21.3", + "argon2": "^0.45.1", "bcryptjs": "^3.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9768924..16858fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -119,6 +119,9 @@ importers: '@tanstack/react-table': specifier: ^8.21.3 version: 8.21.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + argon2: + specifier: ^0.45.1 + version: 0.45.1 bcryptjs: specifier: ^3.0.3 version: 3.0.3 @@ -472,6 +475,9 @@ packages: '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@epic-web/invariant@1.0.0': + resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} + '@eslint-community/eslint-utils@4.10.1': resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -1121,6 +1127,10 @@ packages: resolution: {integrity: sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==} engines: {node: '>= 10.0.0'} + '@phc/format@1.0.0': + resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==} + engines: {node: '>=10'} + '@playwright/test@1.62.1': resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} engines: {node: '>=20'} @@ -2321,6 +2331,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + argon2@0.45.1: + resolution: {integrity: sha512-skm+/WCjkGqCQxF7FG1LuZXM5yvbFjgbfiCGsud2oLgaDhh6b6dbH0b1EkghbM+xx4Bj8Ape+KKgixoIlWZicQ==} + engines: {node: '>=16.17.0'} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -2561,6 +2575,11 @@ packages: crelt@1.0.7: resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==} + cross-env@10.1.0: + resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==} + engines: {node: '>=20'} + hasBin: true + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -3820,6 +3839,10 @@ packages: node-addon-api@7.1.1: resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + node-addon-api@8.9.1: + resolution: {integrity: sha512-4eUQWVPCUUUiBjLnHS3cXWeC6ryoPUc0U3rP7IuzapoGbzMqd/r6KKO0clr0b+snQhsrueFEhCZDdK+LK7hxKg==} + engines: {node: ^18 || ^20 || >= 21} + node-exports-info@1.6.2: resolution: {integrity: sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==} engines: {node: '>= 0.4'} @@ -3827,6 +3850,10 @@ packages: node-fetch-native@1.6.7: resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + node-releases@2.0.51: resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} engines: {node: '>=18'} @@ -5091,6 +5118,8 @@ snapshots: tslib: 2.8.1 optional: true + '@epic-web/invariant@1.0.0': {} + '@eslint-community/eslint-utils@4.10.1(eslint@9.39.5(jiti@2.7.0))': dependencies: eslint: 9.39.5(jiti@2.7.0) @@ -5741,6 +5770,8 @@ snapshots: '@parcel/watcher-win32-arm64': 2.6.0 '@parcel/watcher-win32-x64': 2.6.0 + '@phc/format@1.0.0': {} + '@playwright/test@1.62.1': dependencies: playwright: 1.62.1 @@ -6907,6 +6938,13 @@ snapshots: dependencies: color-convert: 2.0.1 + argon2@0.45.1: + dependencies: + '@phc/format': 1.0.0 + cross-env: 10.1.0 + node-addon-api: 8.9.1 + node-gyp-build: 4.8.4 + argparse@2.0.1: {} aria-hidden@1.2.6: @@ -7168,6 +7206,11 @@ snapshots: crelt@1.0.7: {} + cross-env@10.1.0: + dependencies: + '@epic-web/invariant': 1.0.0 + cross-spawn: 7.0.6 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -8813,6 +8856,8 @@ snapshots: node-addon-api@7.1.1: {} + node-addon-api@8.9.1: {} + node-exports-info@1.6.2: dependencies: array.prototype.flatmap: 1.3.3 @@ -8822,6 +8867,8 @@ snapshots: node-fetch-native@1.6.7: {} + node-gyp-build@4.8.4: {} + node-releases@2.0.51: {} nypm@0.6.9: diff --git a/prisma/migrations/20260802140000_add_communication_escalation/migration.sql b/prisma/migrations/20260802140000_add_communication_escalation/migration.sql index f49951f..b75e546 100644 --- a/prisma/migrations/20260802140000_add_communication_escalation/migration.sql +++ b/prisma/migrations/20260802140000_add_communication_escalation/migration.sql @@ -1,15 +1,39 @@ -ALTER TABLE "communication_rooms" - ADD COLUMN "audienceType" TEXT NOT NULL DEFAULT 'direct', - ADD COLUMN "classGroupId" TEXT, - ADD COLUMN "escalationEligibleAt" TIMESTAMP(3), - ADD COLUMN "escalatedAt" TIMESTAMP(3), - ADD COLUMN "resolvedAt" TIMESTAMP(3), - ADD COLUMN "resolutionStatus" TEXT NOT NULL DEFAULT 'open'; +ALTER TABLE "communication_rooms" ADD COLUMN "audienceType" TEXT NOT NULL DEFAULT 'direct'; +ALTER TABLE "communication_rooms" ADD COLUMN "classGroupId" TEXT; +ALTER TABLE "communication_rooms" ADD COLUMN "escalationEligibleAt" TIMESTAMP(3); +ALTER TABLE "communication_rooms" ADD COLUMN "escalatedAt" TIMESTAMP(3); +ALTER TABLE "communication_rooms" ADD COLUMN "resolvedAt" TIMESTAMP(3); +ALTER TABLE "communication_rooms" ADD COLUMN "resolutionStatus" TEXT NOT NULL DEFAULT 'open'; -ALTER TABLE "communication_rooms" - ADD CONSTRAINT "communication_rooms_classGroupId_fkey" - FOREIGN KEY ("classGroupId") REFERENCES "ClassGroup"("id") - ON DELETE SET NULL ON UPDATE CASCADE; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_communication_rooms" ( + "id" TEXT NOT NULL PRIMARY KEY, + "schoolId" TEXT NOT NULL, + "studentId" TEXT NOT NULL, + "teacherId" TEXT NOT NULL, + "roomType" TEXT NOT NULL DEFAULT 'chat', + "audienceType" TEXT NOT NULL DEFAULT 'direct', + "classGroupId" TEXT, + "status" TEXT NOT NULL DEFAULT 'requested', + "requestedBy" TEXT NOT NULL, + "escalationEligibleAt" DATETIME, + "escalatedAt" DATETIME, + "resolvedAt" DATETIME, + "resolutionStatus" TEXT NOT NULL DEFAULT 'open', + "acceptedAt" DATETIME, + "closedAt" DATETIME, + "closeReason" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "communication_rooms_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "communication_rooms_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "communication_rooms_teacherId_fkey" FOREIGN KEY ("teacherId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "communication_rooms_classGroupId_fkey" FOREIGN KEY ("classGroupId") REFERENCES "ClassGroup" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); +INSERT INTO "new_communication_rooms" ("id", "schoolId", "studentId", "teacherId", "roomType", "audienceType", "classGroupId", "status", "requestedBy", "escalationEligibleAt", "escalatedAt", "resolvedAt", "resolutionStatus", "acceptedAt", "closedAt", "closeReason", "createdAt", "updatedAt") SELECT "id", "schoolId", "studentId", "teacherId", "roomType", "audienceType", "classGroupId", "status", "requestedBy", "escalationEligibleAt", "escalatedAt", "resolvedAt", "resolutionStatus", "acceptedAt", "closedAt", "closeReason", "createdAt", "updatedAt" FROM "communication_rooms"; +DROP TABLE "communication_rooms"; +ALTER TABLE "new_communication_rooms" RENAME TO "communication_rooms"; +PRAGMA foreign_keys=ON; CREATE INDEX "communication_rooms_classGroupId_idx" ON "communication_rooms"("classGroupId"); CREATE INDEX "communication_rooms_escalationEligibleAt_idx" ON "communication_rooms"("escalationEligibleAt"); diff --git a/prisma/migrations/20260802150000_add_leave_approval_workflow/migration.sql b/prisma/migrations/20260802150000_add_leave_approval_workflow/migration.sql index 81097cb..8b792a0 100644 --- a/prisma/migrations/20260802150000_add_leave_approval_workflow/migration.sql +++ b/prisma/migrations/20260802150000_add_leave_approval_workflow/migration.sql @@ -1,21 +1,49 @@ -ALTER TABLE "illness_reports" - ADD COLUMN "leaveType" TEXT NOT NULL DEFAULT 'illness', - ADD COLUMN "teacherNotifiedAt" TIMESTAMP(3), - ADD COLUMN "adminApprovalStatus" TEXT NOT NULL DEFAULT 'pending', - ADD COLUMN "adminApprovedBy" TEXT, - ADD COLUMN "adminApprovedAt" TIMESTAMP(3), - ADD COLUMN "adminNotes" TEXT, - ADD COLUMN "calendarEventId" TEXT; +ALTER TABLE "illness_reports" ADD COLUMN "leaveType" TEXT NOT NULL DEFAULT 'illness'; +ALTER TABLE "illness_reports" ADD COLUMN "teacherNotifiedAt" TIMESTAMP(3); +ALTER TABLE "illness_reports" ADD COLUMN "adminApprovalStatus" TEXT NOT NULL DEFAULT 'pending'; +ALTER TABLE "illness_reports" ADD COLUMN "adminApprovedBy" TEXT; +ALTER TABLE "illness_reports" ADD COLUMN "adminApprovedAt" TIMESTAMP(3); +ALTER TABLE "illness_reports" ADD COLUMN "adminNotes" TEXT; +ALTER TABLE "illness_reports" ADD COLUMN "calendarEventId" TEXT; -ALTER TABLE "illness_reports" - ADD CONSTRAINT "illness_reports_adminApprovedBy_fkey" - FOREIGN KEY ("adminApprovedBy") REFERENCES "User"("id") - ON DELETE SET NULL ON UPDATE CASCADE; - -ALTER TABLE "illness_reports" - ADD CONSTRAINT "illness_reports_calendarEventId_fkey" - FOREIGN KEY ("calendarEventId") REFERENCES "CalendarEvent"("id") - ON DELETE SET NULL ON UPDATE CASCADE; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_illness_reports" ( + "id" TEXT NOT NULL PRIMARY KEY, + "schoolId" TEXT NOT NULL, + "studentId" TEXT NOT NULL, + "reportedBy" TEXT NOT NULL, + "reporterType" TEXT NOT NULL, + "reason" TEXT NOT NULL, + "description" TEXT, + "startDate" DATETIME NOT NULL, + "endDate" DATETIME, + "documentUrl" TEXT, + "leaveType" TEXT NOT NULL DEFAULT 'illness', + "status" TEXT NOT NULL DEFAULT 'pending', + "parentApprovalStatus" TEXT NOT NULL DEFAULT 'pending', + "parentApprovedBy" TEXT, + "parentApprovedAt" DATETIME, + "isVisibleToTeacher" BOOLEAN NOT NULL DEFAULT false, + "isVisibleToAdmin" BOOLEAN NOT NULL DEFAULT false, + "teacherNotifiedAt" DATETIME, + "adminApprovalStatus" TEXT NOT NULL DEFAULT 'pending', + "adminApprovedBy" TEXT, + "adminApprovedAt" DATETIME, + "adminNotes" TEXT, + "calendarEventId" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "illness_reports_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "illness_reports_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "Student" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "illness_reports_reportedBy_fkey" FOREIGN KEY ("reportedBy") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "illness_reports_parentApprovedBy_fkey" FOREIGN KEY ("parentApprovedBy") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "illness_reports_adminApprovedBy_fkey" FOREIGN KEY ("adminApprovedBy") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "illness_reports_calendarEventId_fkey" FOREIGN KEY ("calendarEventId") REFERENCES "CalendarEvent" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); +INSERT INTO "new_illness_reports" ("id", "schoolId", "studentId", "reportedBy", "reporterType", "reason", "description", "startDate", "endDate", "documentUrl", "leaveType", "status", "parentApprovalStatus", "parentApprovedBy", "parentApprovedAt", "isVisibleToTeacher", "isVisibleToAdmin", "teacherNotifiedAt", "adminApprovalStatus", "adminApprovedBy", "adminApprovedAt", "adminNotes", "calendarEventId", "createdAt", "updatedAt") SELECT "id", "schoolId", "studentId", "reportedBy", "reporterType", "reason", "description", "startDate", "endDate", "documentUrl", "leaveType", "status", "parentApprovalStatus", "parentApprovedBy", "parentApprovedAt", "isVisibleToTeacher", "isVisibleToAdmin", "teacherNotifiedAt", "adminApprovalStatus", "adminApprovedBy", "adminApprovedAt", "adminNotes", "calendarEventId", "createdAt", "updatedAt" FROM "illness_reports"; +DROP TABLE "illness_reports"; +ALTER TABLE "new_illness_reports" RENAME TO "illness_reports"; +PRAGMA foreign_keys=ON; CREATE UNIQUE INDEX "illness_reports_calendarEventId_key" ON "illness_reports"("calendarEventId"); CREATE INDEX "illness_reports_adminApprovalStatus_idx" ON "illness_reports"("adminApprovalStatus"); diff --git a/prisma/migrations/20260802160000_add_teacher_absence_approval/migration.sql b/prisma/migrations/20260802160000_add_teacher_absence_approval/migration.sql index 9289b0c..43d4271 100644 --- a/prisma/migrations/20260802160000_add_teacher_absence_approval/migration.sql +++ b/prisma/migrations/20260802160000_add_teacher_absence_approval/migration.sql @@ -1,20 +1,38 @@ -ALTER TABLE "teacher_absences" - ADD COLUMN "approvalStatus" TEXT NOT NULL DEFAULT 'pending', - ADD COLUMN "approvedBy" TEXT, - ADD COLUMN "approvedAt" TIMESTAMP(3), - ADD COLUMN "privateAdminNotes" TEXT, - ADD COLUMN "documentUrl" TEXT, - ADD COLUMN "calendarEventId" TEXT; +ALTER TABLE "teacher_absences" ADD COLUMN "approvalStatus" TEXT NOT NULL DEFAULT 'pending'; +ALTER TABLE "teacher_absences" ADD COLUMN "approvedBy" TEXT; +ALTER TABLE "teacher_absences" ADD COLUMN "approvedAt" TIMESTAMP(3); +ALTER TABLE "teacher_absences" ADD COLUMN "privateAdminNotes" TEXT; +ALTER TABLE "teacher_absences" ADD COLUMN "documentUrl" TEXT; +ALTER TABLE "teacher_absences" ADD COLUMN "calendarEventId" TEXT; -ALTER TABLE "teacher_absences" - ADD CONSTRAINT "teacher_absences_approvedBy_fkey" - FOREIGN KEY ("approvedBy") REFERENCES "User"("id") - ON DELETE SET NULL ON UPDATE CASCADE; - -ALTER TABLE "teacher_absences" - ADD CONSTRAINT "teacher_absences_calendarEventId_fkey" - FOREIGN KEY ("calendarEventId") REFERENCES "CalendarEvent"("id") - ON DELETE SET NULL ON UPDATE CASCADE; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_teacher_absences" ( + "id" TEXT NOT NULL PRIMARY KEY, + "schoolId" TEXT NOT NULL, + "teacherId" TEXT NOT NULL, + "startDate" DATETIME NOT NULL, + "endDate" DATETIME NOT NULL, + "type" TEXT NOT NULL, + "reason" TEXT, + "status" TEXT NOT NULL DEFAULT 'reported', + "notes" TEXT, + "approvalStatus" TEXT NOT NULL DEFAULT 'pending', + "approvedBy" TEXT, + "approvedAt" DATETIME, + "privateAdminNotes" TEXT, + "documentUrl" TEXT, + "calendarEventId" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "teacher_absences_schoolId_fkey" FOREIGN KEY ("schoolId") REFERENCES "School" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "teacher_absences_teacherId_fkey" FOREIGN KEY ("teacherId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "teacher_absences_approvedBy_fkey" FOREIGN KEY ("approvedBy") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "teacher_absences_calendarEventId_fkey" FOREIGN KEY ("calendarEventId") REFERENCES "CalendarEvent" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); +INSERT INTO "new_teacher_absences" ("id", "schoolId", "teacherId", "startDate", "endDate", "type", "reason", "status", "notes", "approvalStatus", "approvedBy", "approvedAt", "privateAdminNotes", "documentUrl", "calendarEventId", "createdAt", "updatedAt") SELECT "id", "schoolId", "teacherId", "startDate", "endDate", "type", "reason", "status", "notes", "approvalStatus", "approvedBy", "approvedAt", "privateAdminNotes", "documentUrl", "calendarEventId", "createdAt", "updatedAt" FROM "teacher_absences"; +DROP TABLE "teacher_absences"; +ALTER TABLE "new_teacher_absences" RENAME TO "teacher_absences"; +PRAGMA foreign_keys=ON; CREATE UNIQUE INDEX "teacher_absences_calendarEventId_key" ON "teacher_absences"("calendarEventId"); CREATE INDEX "teacher_absences_approvalStatus_idx" ON "teacher_absences"("approvalStatus"); diff --git a/prisma/migrations/20260802173000_add_auth_lifecycle/migration.sql b/prisma/migrations/20260802173000_add_auth_lifecycle/migration.sql new file mode 100644 index 0000000..6ab504b --- /dev/null +++ b/prisma/migrations/20260802173000_add_auth_lifecycle/migration.sql @@ -0,0 +1,15 @@ +ALTER TABLE "User" ADD COLUMN "emailVerifiedAt" DATETIME; + +CREATE TABLE "PasswordResetToken" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "tokenHash" TEXT NOT NULL, + "expiresAt" DATETIME NOT NULL, + "usedAt" DATETIME, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "PasswordResetToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE UNIQUE INDEX "PasswordResetToken_tokenHash_key" ON "PasswordResetToken"("tokenHash"); +CREATE INDEX "PasswordResetToken_userId_idx" ON "PasswordResetToken"("userId"); +CREATE INDEX "PasswordResetToken_expiresAt_idx" ON "PasswordResetToken"("expiresAt"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d36f0af..7e9de13 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -148,6 +148,7 @@ model User { role String @default("TEACHER") // TEACHER, SCHOOL_ADMIN, SUPER_ADMIN, STUDENT, PARENT, VICE_PRINCIPAL, DPO locale String @default("de") twoFactorSecret String? + emailVerifiedAt DateTime? isDemo Boolean @default(false) isTeacher Boolean @default(false) // civil-servant status for Personalrat rights createdAt DateTime @default(now()) @@ -243,6 +244,21 @@ model User { // Module J widgets relations dashboardWidgets DashboardWidget[] dashboardLayouts DashboardLayout[] + passwordResetTokens PasswordResetToken[] +} + +model PasswordResetToken { + id String @id @default(cuid()) + userId String + tokenHash String @unique + expiresAt DateTime + usedAt DateTime? + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@index([expiresAt]) } model SchoolYear { diff --git a/prisma/seed-demo.ts b/prisma/seed-demo.ts index 9ad2092..c92ba62 100644 --- a/prisma/seed-demo.ts +++ b/prisma/seed-demo.ts @@ -1,5 +1,5 @@ import { PrismaClient } from '@prisma/client' -import bcrypt from 'bcryptjs' +import argon2 from 'argon2' const prisma = new PrismaClient() const password = 'Demo2025!' @@ -13,7 +13,12 @@ const demoUsers = [ ] async function main() { - const passwordHash = await bcrypt.hash(password, 10) + const passwordHash = await argon2.hash(password, { + type: argon2.argon2id, + memoryCost: 19 * 1024, + timeCost: 2, + parallelism: 1, + }) for (const user of demoUsers) { await prisma.user.upsert({ diff --git a/prisma/seed.ts b/prisma/seed.ts index 53cf794..326c36c 100755 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -6,7 +6,7 @@ */ import { PrismaClient } from '@prisma/client' -import bcrypt from 'bcryptjs' +import argon2 from 'argon2' const prisma = new PrismaClient() @@ -96,7 +96,12 @@ async function main() { // ─── 3. Demo Users ────────────────────────────────────────────────── console.log(' 👩‍🏫 Creating demo users...') - const passwordHash = await bcrypt.hash('Demo2025!', 10) + const passwordHash = await argon2.hash('Demo2025!', { + type: argon2.argon2id, + memoryCost: 19 * 1024, + timeCost: 2, + parallelism: 1, + }) const teacher = await prisma.user.create({ data: { schoolId: school.id, diff --git a/scripts/sqlite-backup.mjs b/scripts/sqlite-backup.mjs new file mode 100644 index 0000000..a888104 --- /dev/null +++ b/scripts/sqlite-backup.mjs @@ -0,0 +1,32 @@ +import { access, mkdir, stat } from 'node:fs/promises'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +const databaseUrl = process.env.DATABASE_URL; +if (!databaseUrl?.startsWith('file:')) { + throw new Error('DATABASE_URL must use a SQLite file: URL'); +} + +const source = path.resolve(databaseUrl.slice('file:'.length)); +const backupDirectory = path.resolve(process.env.BACKUP_PATH || path.join(process.cwd(), 'data', 'backups')); +const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); +const destination = path.join(backupDirectory, `schulos-sqlite-${timestamp}.db`); + +await access(source); +await mkdir(backupDirectory, { recursive: true }); + +const result = spawnSync('sqlite3', [source, `.backup '${destination.replaceAll("'", "''")}'`], { + encoding: 'utf8', +}); + +if (result.error || result.status !== 0) { + throw result.error ?? new Error(result.stderr || 'SQLite online backup failed'); +} + +const integrity = spawnSync('sqlite3', [destination, 'PRAGMA integrity_check;'], { encoding: 'utf8' }); +if (integrity.error || integrity.status !== 0 || integrity.stdout.trim() !== 'ok') { + throw integrity.error ?? new Error(`Backup integrity check failed: ${integrity.stdout || integrity.stderr}`); +} + +const backupStat = await stat(destination); +console.log(JSON.stringify({ backup: destination, bytes: backupStat.size, integrity: 'ok' })); diff --git a/scripts/sqlite-wal.mjs b/scripts/sqlite-wal.mjs new file mode 100644 index 0000000..91b5823 --- /dev/null +++ b/scripts/sqlite-wal.mjs @@ -0,0 +1,23 @@ +import { access, mkdir } from 'node:fs/promises'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +const databaseUrl = process.env.DATABASE_URL; + +if (!databaseUrl?.startsWith('file:')) { + throw new Error('DATABASE_URL must use a SQLite file: URL'); +} + +const databasePath = path.resolve(databaseUrl.slice('file:'.length)); +await mkdir(path.dirname(databasePath), { recursive: true }); + +const result = spawnSync('sqlite3', [databasePath, 'PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=ON; PRAGMA wal_checkpoint(TRUNCATE);'], { + encoding: 'utf8', +}); + +if (result.error || result.status !== 0) { + throw result.error ?? new Error(result.stderr || 'Unable to configure SQLite WAL mode'); +} + +await access(databasePath); +console.log(`SQLite WAL configured: ${databasePath}`); diff --git a/src/app/api/auth/route.ts b/src/app/api/auth/route.ts index f576ae6..01bb865 100755 --- a/src/app/api/auth/route.ts +++ b/src/app/api/auth/route.ts @@ -4,6 +4,7 @@ import { db } from '@/lib/db'; import { hashPassword, verifyPassword, + needsPasswordRehash, createSession, getSession, clearSession, @@ -12,6 +13,7 @@ import { getSessionCookieOptions, } from '@/lib/auth'; import { withRateLimit, checkRateLimit, getRateLimitKey, getRateLimitHeaders, createRateLimitResponse } from '@/lib/rate-limit'; +import { createPasswordResetToken, hashResetToken, passwordResetExpiry } from '@/lib/password-reset'; const loginSchema = z.object({ email: z.string().email(), @@ -26,11 +28,69 @@ const registerSchema = z.object({ schoolId: z.string().optional(), }); +const requestPasswordResetSchema = z.object({ + email: z.string().email(), +}); + +const resetPasswordSchema = z.object({ + token: z.string().min(40), + password: z.string().min(12), +}); + export const POST = withRateLimit(async function POST(request: Request) { try { const body = await request.json(); const { action } = body; + if (action === 'request-password-reset') { + const parsed = requestPasswordResetSchema.safeParse(body); + if (!parsed.success) return NextResponse.json({ error: 'Validation failed' }, { status: 400 }); + + const key = `password-reset:${getRateLimitKey(request)}`; + const result = checkRateLimit(key, 5, 60 * 60 * 1000); + if (!result.allowed) { + const response = createRateLimitResponse(result); + return NextResponse.json(response.body, { status: response.status, headers: response.headers }); + } + + const user = await db.user.findFirst({ where: { email: parsed.data.email, deletedAt: null } }); + let developmentToken: string | undefined; + if (user) { + await db.passwordResetToken.deleteMany({ where: { userId: user.id, usedAt: null } }); + const { token, tokenHash } = createPasswordResetToken(); + await db.passwordResetToken.create({ + data: { userId: user.id, tokenHash, expiresAt: passwordResetExpiry() }, + }); + if (process.env.NODE_ENV !== 'production' || process.env.AUTH_SHOW_RESET_TOKEN === 'true') { + developmentToken = token; + } + } + + return NextResponse.json({ + message: 'If the account exists, reset instructions have been prepared.', + ...(developmentToken ? { developmentToken } : {}), + }); + } + + if (action === 'reset-password') { + const parsed = resetPasswordSchema.safeParse(body); + if (!parsed.success) return NextResponse.json({ error: 'Validation failed' }, { status: 400 }); + + const reset = await db.passwordResetToken.findFirst({ + where: { tokenHash: hashResetToken(parsed.data.token), usedAt: null, expiresAt: { gt: new Date() } }, + include: { user: true }, + }); + if (!reset || reset.user.deletedAt) { + return NextResponse.json({ error: 'Invalid or expired reset token' }, { status: 400 }); + } + + await db.$transaction([ + db.user.update({ where: { id: reset.userId }, data: { passwordHash: await hashPassword(parsed.data.password) } }), + db.passwordResetToken.update({ where: { id: reset.id }, data: { usedAt: new Date() } }), + ]); + return NextResponse.json({ message: 'Password reset complete' }); + } + // ── Login ── if (action === 'login') { const parsed = loginSchema.safeParse(body); @@ -62,6 +122,13 @@ export const POST = withRateLimit(async function POST(request: Request) { ); } + if (needsPasswordRehash(user.passwordHash)) { + await db.user.update({ + where: { id: user.id }, + data: { passwordHash: await hashPassword(password) }, + }); + } + const secure = isSecureRequest(request); await createSession(user.id, secure); @@ -112,6 +179,7 @@ export const POST = withRateLimit(async function POST(request: Request) { lastName, schoolId: schoolId ?? null, role: 'TEACHER', + emailVerifiedAt: process.env.AUTH_REQUIRE_EMAIL_VERIFICATION === 'true' ? null : new Date(), }, }); diff --git a/src/app/api/backup/route.ts b/src/app/api/backup/route.ts index 932095f..d6067f5 100755 --- a/src/app/api/backup/route.ts +++ b/src/app/api/backup/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { withRateLimit } from '@/lib/rate-limit'; +import { deleteStorageFile, readStorageFile, writeStorageFile } from '@/lib/storage'; // GET /api/backup — List backups for a school export async function GET(req: Request) { @@ -41,9 +42,12 @@ export const POST = withRateLimit(async function POST(req: Request) { if (!backup || backup.status !== 'completed') { return NextResponse.json({ error: 'Backup not found or not completed' }, { status: 404 }); } - // In a real implementation, we would restore the DB from the backup JSON - // For now, we just mark the restore as acknowledged - return NextResponse.json({ message: 'Backup restore initiated', backup }); + const content = await readStorageFile('backups', `${backup.schoolId}/${backup.filename}`); + return NextResponse.json({ + message: 'Backup file verified. Restore must run in a disposable maintenance environment.', + backup, + bytes: content.byteLength, + }); } // Create a new backup — export entire DB as JSON @@ -106,6 +110,7 @@ export const POST = withRateLimit(async function POST(req: Request) { const jsonStr = JSON.stringify(backupData, null, 2); const size = Buffer.byteLength(jsonStr, 'utf-8'); + await writeStorageFile('backups', `${schoolId}/${filename}`, jsonStr); const backup = await db.backup.create({ data: { @@ -120,7 +125,6 @@ export const POST = withRateLimit(async function POST(req: Request) { return NextResponse.json({ ...backup, - data: backupData, // Include the actual backup data for download }); } catch (error) { console.error('Backup create error:', error); @@ -151,6 +155,11 @@ export async function DELETE(req: NextRequest) { if (!id) { return NextResponse.json({ error: 'id is required' }, { status: 400 }); } + const backup = await db.backup.findUnique({ where: { id } }); + if (!backup) { + return NextResponse.json({ error: 'Backup not found' }, { status: 404 }); + } + await deleteStorageFile('backups', `${backup.schoolId}/${backup.filename}`); await db.backup.delete({ where: { id } }); return NextResponse.json({ message: 'Backup deleted' }); } catch (error) { diff --git a/src/lib/auth.test.ts b/src/lib/auth.test.ts new file mode 100644 index 0000000..7db4d74 --- /dev/null +++ b/src/lib/auth.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from 'bun:test'; +import bcrypt from 'bcryptjs'; +import { hashPassword, needsPasswordRehash, verifyPassword } from './auth'; + +describe('password hashing', () => { + test('creates and verifies Argon2id hashes', async () => { + const hash = await hashPassword('correct horse battery staple'); + + expect(hash.startsWith('$argon2id$')).toBeTrue(); + expect(await verifyPassword('correct horse battery staple', hash)).toBeTrue(); + expect(await verifyPassword('incorrect password', hash)).toBeFalse(); + expect(needsPasswordRehash(hash)).toBeFalse(); + }); + + test('verifies legacy bcrypt hashes for rehash-on-login migration', async () => { + const legacyHash = await bcrypt.hash('legacy-password', 10); + + expect(await verifyPassword('legacy-password', legacyHash)).toBeTrue(); + expect(needsPasswordRehash(legacyHash)).toBeTrue(); + }); +}); diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 352a718..9996424 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,10 +1,22 @@ import bcrypt from 'bcryptjs'; +import argon2 from 'argon2'; import { cookies } from 'next/headers'; import { Prisma } from '@prisma/client'; import { db } from '@/lib/db'; export const SESSION_COOKIE_NAME = 'ct_session'; -export const SESSION_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; // 7 days +const DEFAULT_SESSION_MAX_AGE = 60 * 60 * 24 * 7; +const parsedSessionMaxAge = Number.parseInt(process.env.SESSION_MAX_AGE_SECONDS ?? '', 10); +export const SESSION_COOKIE_MAX_AGE = Number.isFinite(parsedSessionMaxAge) && parsedSessionMaxAge >= 60 + ? parsedSessionMaxAge + : DEFAULT_SESSION_MAX_AGE; + +const ARGON2_OPTIONS = { + type: argon2.argon2id, + memoryCost: 19 * 1024, + timeCost: 2, + parallelism: 1, +} as const; /** * Determine whether the Secure flag should be set on cookies. @@ -32,17 +44,23 @@ export function getSessionCookieOptions(secure: boolean) { } export async function hashPassword(password: string): Promise { - const salt = await bcrypt.genSalt(10); - return bcrypt.hash(password, salt); + return argon2.hash(password, { ...ARGON2_OPTIONS, raw: false }); } export async function verifyPassword( password: string, hash: string ): Promise { + if (hash.startsWith('$argon2')) { + return argon2.verify(hash, password); + } return bcrypt.compare(password, hash); } +export function needsPasswordRehash(hash: string): boolean { + return !hash.startsWith('$argon2'); +} + export async function createSession(userId: string, secure?: boolean): Promise { const cookieStore = await cookies(); const cookieSecure = secure ?? (process.env.NODE_ENV === 'production'); diff --git a/src/lib/csrf.test.ts b/src/lib/csrf.test.ts new file mode 100644 index 0000000..d9e5bc4 --- /dev/null +++ b/src/lib/csrf.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from 'bun:test'; +import { isCsrfProtectedRequest, isTrustedRequestOrigin } from './csrf'; + +describe('CSRF origin protection', () => { + test('allows safe methods without origin headers', () => { + const request = new Request('https://school.example/api/students'); + expect(isCsrfProtectedRequest(request)).toBeFalse(); + expect(isTrustedRequestOrigin(request)).toBeTrue(); + }); + + test('allows mutations from the request origin', () => { + const request = new Request('https://school.example/api/students', { + method: 'POST', + headers: { origin: 'https://school.example' }, + }); + expect(isCsrfProtectedRequest(request)).toBeTrue(); + expect(isTrustedRequestOrigin(request)).toBeTrue(); + }); + + test('rejects cross-origin mutation requests', () => { + const request = new Request('https://school.example/api/students', { + method: 'DELETE', + headers: { origin: 'https://attacker.example' }, + }); + expect(isTrustedRequestOrigin(request)).toBeFalse(); + }); +}); diff --git a/src/lib/csrf.ts b/src/lib/csrf.ts new file mode 100644 index 0000000..8552c2d --- /dev/null +++ b/src/lib/csrf.ts @@ -0,0 +1,41 @@ +const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); + +function normalizeOrigin(value: string): string | null { + try { + return new URL(value).origin; + } catch { + return null; + } +} + +function configuredOrigins(): Set { + return new Set( + (process.env.NEXT_PUBLIC_APP_URL ?? '') + .split(',') + .map((origin) => normalizeOrigin(origin.trim())) + .filter((origin): origin is string => Boolean(origin)) + ); +} + +export function isCsrfProtectedRequest(request: Request): boolean { + return !SAFE_METHODS.has(request.method.toUpperCase()) + && new URL(request.url).pathname.startsWith('/api/'); +} + +export function isTrustedRequestOrigin(request: Request): boolean { + if (!isCsrfProtectedRequest(request)) return true; + + const requestOrigin = normalizeOrigin(request.url); + const origin = normalizeOrigin(request.headers.get('origin') ?? ''); + const referer = normalizeOrigin(request.headers.get('referer') ?? ''); + const allowedOrigins = configuredOrigins(); + + if (requestOrigin) allowedOrigins.add(requestOrigin); + if (process.env.NODE_ENV !== 'production') { + allowedOrigins.add('http://localhost:3000'); + allowedOrigins.add('http://127.0.0.1:3000'); + } + + const suppliedOrigin = origin ?? referer; + return suppliedOrigin !== null && allowedOrigins.has(suppliedOrigin); +} diff --git a/src/lib/password-reset.test.ts b/src/lib/password-reset.test.ts new file mode 100644 index 0000000..4583c54 --- /dev/null +++ b/src/lib/password-reset.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from 'bun:test'; +import { createPasswordResetToken, hashResetToken, passwordResetExpiry } from './password-reset'; + +describe('password reset tokens', () => { + test('creates opaque tokens and stable SHA-256 lookup hashes', () => { + const { token, tokenHash } = createPasswordResetToken(); + expect(token.length).toBeGreaterThan(30); + expect(tokenHash).toBe(hashResetToken(token)); + expect(tokenHash).not.toBe(token); + }); + + test('uses a one-hour default expiry', () => { + const now = new Date('2026-08-02T10:00:00.000Z'); + expect(passwordResetExpiry(now).toISOString()).toBe('2026-08-02T11:00:00.000Z'); + }); +}); diff --git a/src/lib/password-reset.ts b/src/lib/password-reset.ts new file mode 100644 index 0000000..999fa25 --- /dev/null +++ b/src/lib/password-reset.ts @@ -0,0 +1,21 @@ +import { createHash, randomBytes } from 'node:crypto'; + +const TOKEN_BYTES = 32; +const DEFAULT_TOKEN_TTL_SECONDS = 60 * 60; + +export function hashResetToken(token: string): string { + return createHash('sha256').update(token).digest('hex'); +} + +export function createPasswordResetToken(): { token: string; tokenHash: string } { + const token = randomBytes(TOKEN_BYTES).toString('base64url'); + return { token, tokenHash: hashResetToken(token) }; +} + +export function passwordResetExpiry(now = new Date()): Date { + const configuredTtl = Number.parseInt(process.env.PASSWORD_RESET_TTL_SECONDS ?? '', 10); + const ttl = Number.isFinite(configuredTtl) && configuredTtl >= 300 + ? configuredTtl + : DEFAULT_TOKEN_TTL_SECONDS; + return new Date(now.getTime() + ttl * 1000); +} diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index 9880c64..1484e50 100755 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -26,6 +26,9 @@ export const RATE_LIMITS = { // Auth POST endpoints: 30 requests per minute (allows retries & multiple tabs) auth: { limit: 30, windowMs: 60 * 1000 }, + // Password reset requests: 5 requests per hour per source IP. + passwordReset: { limit: 5, windowMs: 60 * 60 * 1000 }, + // Auth GET endpoint: 120 requests per minute (high frequency, low cost) authGet: { limit: 120, windowMs: 60 * 1000 }, diff --git a/src/lib/storage.test.ts b/src/lib/storage.test.ts new file mode 100644 index 0000000..99f9792 --- /dev/null +++ b/src/lib/storage.test.ts @@ -0,0 +1,31 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { mkdtemp, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { deleteStorageFile, readStorageFile, storagePath, writeStorageFile } from './storage'; + +const originalBackupPath = process.env.BACKUP_PATH; +const tempDirectories: string[] = []; + +afterEach(async () => { + process.env.BACKUP_PATH = originalBackupPath; + await Promise.all(tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); +}); + +describe('local storage', () => { + test('writes, reads, and deletes files inside the configured storage area', async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'schulos-storage-')); + tempDirectories.push(directory); + process.env.BACKUP_PATH = directory; + + await writeStorageFile('backups', 'school-1/backup.json', '{"version":1}'); + + expect((await readStorageFile('backups', 'school-1/backup.json')).toString()).toBe('{"version":1}'); + await deleteStorageFile('backups', 'school-1/backup.json'); + expect(storagePath('backups', 'school-1/backup.json')).toContain(directory); + }); + + test('rejects path traversal outside storage root', () => { + expect(() => storagePath('backups', '../../outside.json')).toThrow('Storage path must remain within its configured area'); + }); +}); diff --git a/src/lib/storage.ts b/src/lib/storage.ts new file mode 100644 index 0000000..c332c98 --- /dev/null +++ b/src/lib/storage.ts @@ -0,0 +1,50 @@ +import path from 'node:path'; +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; + +export type StorageArea = 'uploads' | 'exports' | 'backups'; + +const DEFAULT_STORAGE_ROOT = path.join(process.cwd(), 'data'); + +function getAreaRoot(area: StorageArea): string { + const configured = area === 'uploads' + ? process.env.STORAGE_PATH + : area === 'exports' + ? process.env.EXPORT_PATH + : process.env.BACKUP_PATH; + + return path.resolve(configured || path.join(DEFAULT_STORAGE_ROOT, area)); +} + +function getSafePath(area: StorageArea, relativePath: string): string { + const root = getAreaRoot(area); + const target = path.resolve(root, relativePath); + + if (target !== root && !target.startsWith(`${root}${path.sep}`)) { + throw new Error('Storage path must remain within its configured area'); + } + + return target; +} + +export function storagePath(area: StorageArea, relativePath: string): string { + return getSafePath(area, relativePath); +} + +export async function writeStorageFile( + area: StorageArea, + relativePath: string, + content: string | Buffer +): Promise { + const target = getSafePath(area, relativePath); + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, content, { mode: 0o600 }); + return target; +} + +export async function readStorageFile(area: StorageArea, relativePath: string): Promise { + return readFile(getSafePath(area, relativePath)); +} + +export async function deleteStorageFile(area: StorageArea, relativePath: string): Promise { + await rm(getSafePath(area, relativePath), { force: true }); +}