Add 21 files, update 16 files, rename 1 file
This commit is contained in:
1 parent
b9e4ed4dfc
commit
278293b397
39 files changed
+2434
-61
No files matched your search
@@ -0,0 +1,12 @@
|
||||
.git
|
||||
.github
|
||||
.next
|
||||
node_modules
|
||||
test-results
|
||||
playwright-report
|
||||
db
|
||||
data
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
docs
|
||||
+36
-4
@@ -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=
|
||||
@@ -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
|
||||
@@ -43,6 +43,7 @@ next-env.d.ts
|
||||
# database
|
||||
db/*.db
|
||||
db/*.db-journal
|
||||
data/
|
||||
prisma/*.db
|
||||
prisma/*.db-journal
|
||||
|
||||
|
||||
+32
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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:
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
+1116
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -138,7 +138,7 @@ schulos/
|
||||
|
||||
## License
|
||||
|
||||
MIT License — see [LICENSE](./LICENSE) for details.
|
||||
MIT License — see [LICENSE](../LICENSE) for details.
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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*'],
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
Generated
+47
@@ -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:
|
||||
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
@@ -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 {
|
||||
|
||||
+7
-2
@@ -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({
|
||||
|
||||
+7
-2
@@ -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,
|
||||
|
||||
@@ -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' }));
|
||||
@@ -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}`);
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
+21
-3
@@ -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<string> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
const cookieStore = await cookies();
|
||||
const cookieSecure = secure ?? (process.env.NODE_ENV === 'production');
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string> {
|
||||
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);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<string> {
|
||||
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<Buffer> {
|
||||
return readFile(getSafePath(area, relativePath));
|
||||
}
|
||||
|
||||
export async function deleteStorageFile(area: StorageArea, relativePath: string): Promise<void> {
|
||||
await rm(getSafePath(area, relativePath), { force: true });
|
||||
}
|
||||
Reference in new issue
Block a user