ADR-001 — Samlet forløbsmodel (forløb, emne, pr.-gruppe-opgaver, én deadline)¶
Status: GODKENDT 2026-06-11 Dato: 2026-06-11 Kontekst: docs/STATUS-AUDIT.md §3 + mødenoternes domænemodel (CLAUDE.md)
Baggrund og problem¶
Mødenoterne definerer et FORLØB som den pædagogiske enhed (f.eks. "Geometri") med EMNER som underenheder (f.eks. "Figurer"), og forretningsreglen én gruppe = én aflevering (antal aktive opgaver == antal aktive grupper, altid).
Den nuværende datamodel har ingen af delene:
| Begreb i mødenoterne | I koden i dag | Problem |
|---|---|---|
| Forløb ("Geometri") | findes ikke — Class er et hold (elever, skema, indskrivning) |
Audit'ens §3.1 mapper roadmap-"forløb" til Class, men det er en fejlkilde: et hold har mange forløb over et år |
| Emne ("Figurer") | Group.topic — fritekst pr. gruppe |
Kan ikke forespørges/grupperes pålideligt; intet bånd mellem grupper om samme emne |
| Opgave pr. gruppe | Assignment er klasse-global; GroupAssignment har intet descriptionOverride/isShared |
Roadmap-punkt 1 umuligt uden schema-ændring (audit §3.3) |
| Én frist | Både Assignment.deadline (required) og Group.deadline (valgfri) |
To potentielt modstridende kilder (audit §3.3) |
Beslutning 1 — Forløb som ny entitet Course; emnet bæres af Assignment¶
Ny model Course (da: forløb)¶
model Course {
id String @id @default(uuid())
name String // "Geometri"
description String?
classId String // forløbet hører til ÉT hold
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
subjectId String // ... og ÉT fag ("Matematik")
subject Subject @relation(fields: [subjectId], references: [id])
status String @default("ACTIVE") // ACTIVE | ARCHIVED
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
assignments Assignment[]
groups Group[]
@@unique([classId, subjectId, name])
@@index([classId])
@@map("courses")
}
Organisations-scoping sker via class.organizationId (samme mønster som
Group); ejerskab via class.teacherId.
Emnet er IKKE en separat tabel — emnet ER opgaverunden¶
Et "emne" ("Figurer") opstår i praksis kun som én opgaverunde i et forløb:
oprettelsesflowet (FASE C) opretter emne + grupper + opgavebeskrivelse i
ét hug. Derfor bæres emnet af den eksisterende Assignment:
Assignment.title= emne-navnet ("Figurer")Assignment.description= opgavebeskrivelsenAssignment.courseId?(ny, nullable FK) = hvilket forløb emnet hører til
"Alle grupper med dette emne" er dermed alle grupper knyttet til samme
Assignment via GroupAssignment — en eksakt relation, ikke
streng-matchning på fritekst. Det gør bulk-redigering ("Anvend på alle
grupper med dette emne") robust og triviel at afgrænse.
Group.courseId? (ny, nullable FK) tilføjes OGSÅ, så grupper kan placeres
i forløbs-/fag-mappestrukturen uafhængigt af om de har en aktiv opgave
(legacy-grupper og grupper oprettet før en opgave). Invariant (håndhæves
i service-laget): når en gruppe har en aktiv GroupAssignment, er
group.courseId === assignment.courseId.
Group.topic (fritekst) deprecates: nye flows skriver ikke til det;
det vises kun for legacy-grupper uden forløb. Droppes i kontrakt-
migrationen (se Beslutning 4).
Forkastede alternativer¶
- Genbrug
Classsom forløb. Nej —Classer en persongruppe med indskrivning, skema og homeroom. Et hold har flere samtidige/sekventielle forløb. At overbelasteClassville tvinge eleverne til at være "indskrevet" pr. forløb og smadre alle eksisterende multi-tenancy- og ejerskabsqueries. - Genbrug
Subjectsom forløb. Nej — fag er organisationens taksonomi ("Matematik"); forløb er lærerens planlagte sekvens for ét hold. Samme forløbsnavn kan findes i mange hold uden at være samme forløb. - Separat
Topic/Emne-tabel under Course. Forkastet (YAGNI): emnet har ingen egne data ud over navn + opgaveindhold, og en ekstra tabel ville kræve endnu en join i alle visninger. Skulle behovet opstå (flere opgaverunder pr. emne), kan enCourseTopicindskydes senere uden at bryde denne model —Assignment.titleflytter blot op.
Beslutning 2 — Pr.-gruppe-opgaver: isShared på Assignment + overrides på GroupAssignment¶
model Assignment {
// eksisterende felter ...
courseId String? // ny (Beslutning 1)
isShared Boolean @default(true) // ny: delt (true) eller unik pr. gruppe (false)
}
model GroupAssignment {
// eksisterende felter ...
descriptionOverride String? // ny: gruppens egen opgavetekst (kun brugt når !isShared)
}
model AssignmentAttachment {
// eksisterende felter ...
groupAssignmentId String? // ny, nullable FK: null = delt fil for hele emnet,
// sat = fil KUN for den gruppe (onDelete: Cascade)
}
Effektiv opgavetekst for en gruppe (én regel, ét sted i service-laget):
effectiveDescription(ga) =
ga.assignment.isShared ? ga.assignment.description
: (ga.descriptionOverride ?? ga.assignment.description)
Samme regel for filer: gruppen ser emnets delte filer
(groupAssignmentId IS NULL) plus sine egne (groupAssignmentId = ga.id).
Hvorfor ikke flytte Assignment til gruppe-niveau? Det blev overvejet (én Assignment-række pr. gruppe = bogstavelig "én gruppe = én aflevering"), men forkastet:
- Forretningsreglen er allerede opfyldt strukturelt:
GroupAssignmentER afleveringen —@@unique([assignmentId, groupId])findes, og hele submission-/grade-/feedback-kæden (Submission, currentSubmissionId) hænger på GroupAssignment i dag. Intet skal flyttes. - Delt opgavetekst ville blive duplikeret i N rækker → redigering af et delt emne bliver N opdateringer med drift-risiko, og "samme emne" ville igen kræve streng-matchning.
- Lærerens afleveringsoverblik (#7, DONE) aggregerer pr. Assignment — det ville skulle genopfindes.
- Migreringen ville være destruktiv (split af eksisterende rækker) i stedet for rent additiv (se Beslutning 4).
Toggle-semantik: skift unik → delt sletter IKKE eksisterende
descriptionOverrides/pr.-gruppe-filer — de ignoreres blot (reversibelt,
ikke-destruktivt). UI viser altid den effektive tekst.
Beslutning 3 — Én kilde til deadline: Assignment.deadline¶
Assignment.deadlineer sandheden. Det er den, elev-UI'et allerede bruger overalt (StudentAssignment,GroupDetailsviserga.assignment.deadline). Én frist pr. emne — sat i wizard-trin 6.Group.deadlinedeprecates. Nye flows skriver aldrig til feltet; læsning erstattes af gruppens aktiveassignment.deadline.Group.startDateer uberørt — det styrer elevsynlighed (FASE B) og sættes fortsat af wizard-trin 6.
Migrering af eksisterende data:
| Situation | Handling |
|---|---|
Gruppe har aktiv opgave, group.deadline ≠ assignment.deadline |
assignment.deadline vinder (det er hvad eleverne ser i dag); ingen dataflytning |
Gruppe har aktiv opgave, assignment.deadline mangler |
Kan ikke forekomme — kolonnen er NOT NULL i dag |
Gruppe har deadline men ingen opgave |
Værdien bliver stående i kolonnen i deprecation-vinduet og vises som hidtil for legacy-grupper; den droppes først i kontrakt-migrationen, og MR'en for den vedlægger en optælling af berørte rækker |
Beslutning 4 — Migrations-plan: expand/contract, additiv først¶
Release 1 (FASE B — denne omgang). Udelukkende additivt:
CREATE TABLE "courses" (...); -- ny tabel
ALTER TABLE "assignments" ADD COLUMN "courseId" TEXT NULL; -- + FK
ALTER TABLE "assignments" ADD COLUMN "isShared" BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE "GroupAssignment" ADD COLUMN "descriptionOverride" TEXT NULL;
ALTER TABLE "assignment_attachments" ADD COLUMN "groupAssignmentId" TEXT NULL; -- + FK
ALTER TABLE "groups" ADD COLUMN "courseId" TEXT NULL; -- + FK
- Alle kolonner er nullable eller har konstant default ⇒ ingen table
rewrites i Postgres 14; kun korte metadata-låse. Migrationen kører som
altid via
prisma migrate deployi entrypoint — nedetiden er den normale backend-genstart på få sekunder (frontend-nginx 502'er kort, som ved ethvert deploy). Ingen ekstra nedetid. - Ingen automatisk backfill af forløb. Vi kan ikke gætte forløbsnavne
("Geometri") ud fra eksisterende data. Legacy-grupper/-opgaver har
courseId = NULLog vises i en "Uden forløb"-mappe; læreren kan knytte dem til et forløb via redigeringsflowet (FASE C).isShareddefaulter tiltrue, hvilket er præcis nutidens semantik — adfærden for al eksisterende data er uændret. - Migrationen testes mod en kopi af produktionsdata (pg_dump af groopworks-main-db, samme procedure som FASE C's legacy-felt-migration).
Release 2 (kontrakt — separat MR, tidligst når FASE C-UI'et har været i
drift): drop Group.deadline og Group.topic. Forudsætning: ingen
læsninger i kode, og optælling af rækker med "deadline uden opgave"
vedlagt MR'en.
Rollback: Release 1 kan rulles tilbage ved at deploye forrige image — de nye kolonner/tabeller er harmløse for gammel kode (ukendte kolonner ignoreres af Prisma). Ingen destruktive ændringer før Release 2.
Beslutning 5 — Berørte endpoints og UI¶
Backend (FASE B implementerer; eksisterende flows må ikke brække)¶
| Område | Ændring |
|---|---|
NY routes/course.routes.ts |
CRUD: GET/POST /api/courses (lærer/admin, org-scopet), GET/PATCH/DELETE /api/courses/:id; GET /api/courses?classId=&subjectId=&search= til den søgbare dropdown |
assignment.routes/controller/service |
create/update accepterer courseId, isShared, pr.-gruppe-descriptionOverrides; svar beriges med effectiveDescription pr. groupAssignment; upload-endpointet accepterer groupAssignmentId |
group.routes/controller/service |
create/update accepterer courseId; list-svar inkluderer course (til mappestruktur); invariant-håndhævelse gruppe↔opgave-courseId |
| NYT bulk-endpoint | PATCH /api/assignments/:id/bulk ("anvend på alle grupper med dette emne"): opdaterer KUN delte felter (titel/beskrivelse/deadline/delte filer/datoer) — rører ALDRIG medlemmer, descriptionOverrides eller pr.-gruppe-filer. Svarer med berørte gruppe-id'er til preview ("Dette ændrer 6 grupper i …") via et ?dryRun=1-flag |
logging.service / ActivityLog |
nye actions: COURSE CREATE/UPDATE/DELETE, ASSIGNMENT BULK_UPDATE (med berørte gruppe-id'er i details) |
| Workroom/aflevering | WorkRoomFolderView-afleveringsdialogens opgaveliste uændret (GroupAssignment-kæden er intakt) |
| Sockets | uændrede event-navne; assignment:updated-payload får isShared/courseId med |
Frontend (FASE C implementerer; FASE B holder UI-ændringer minimale)¶
| Komponent | Ændring |
|---|---|
pages/groups/views/CreateGroup.tsx + creation/* |
erstattes af samlet wizard (fag → emne → forløb → beskrivelse m. unik/delt → filer m. samme toggle → datoer → gruppefordeling m. drag-drop + sekventiel picker) |
pages/assignments/teacher/components/CreateAssignment.tsx |
gamle separate indgang skjules (bevares bag rute for bagudkompatibilitet i en periode) |
EditGroupDialog / EditAssignemt |
samles til redigeringsflow med alt fra oprettelsen + bulk-checkbox med preview |
TeacherGroups.tsx |
fag-/forløbs-mappestruktur som elevvisningen (genbrug utils/subjectGrouping-mønstret), mapper lukkede som default; "Uden forløb"-mappe |
StudentAssignment.tsx / AssignmentDetails / GroupDetails |
viser effektiv (pr.-gruppe-) beskrivelse + filer; deadline læses kun fra assignment |
| Redux | ny courseSlice; assignmentSlice/groupSlice udvides med courseId/isShared/overrides |
Udtrykkeligt uændret¶
Auth/multi-tenancy, WorkRoom/OnlyOffice-kæden, Submission/grade-kæden,
elevsynlighed via Group.startDate, socket-rumstrukturen.
Konsekvenser og risici¶
- ✅ Additiv Release 1 ⇒ ingen adfærdsændring for eksisterende data
(
isShared=true= nutidens semantik), triviel rollback. - ✅ Bulk-redigering afgrænses af relationer (samme Assignment), ikke streng-matchning — "ALDRIG medlemmer, ALDRIG gruppe-unikt indhold" kan håndhæves i ét service-lag.
- ⚠️ Denormaliseret
courseIdpå både Group og Assignment kræver en service-invariant (dækkes af tests i FASE B). - ⚠️ Legacy-data uden forløb lander i "Uden forløb" — lærerne skal selv rydde op via redigeringsflowet. Accepteret: alternativet (gættede forløbsnavne) er værre.
- ⚠️ To-trins migrering betyder at
Group.deadline/Group.topiclever som døde kolonner i et deprecation-vindue. Accepteret for nul nedetid.
Opfølgning¶
- FASE B: schema + migrations + service-lag + tests (på kopi af
produktionsdata) +
docs/architecture.md. - FASE C: samlet oprettelses-/redigeringswizard + mappestruktur i lærervisning + bulk-preview/ActivityLog.
- Senere: kontrakt-migration (drop
Group.deadline/Group.topic).