Gå til indholdet

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 ikkeClass 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 = opgavebeskrivelsen
  • Assignment.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 Class som forløb. Nej — Class er en persongruppe med indskrivning, skema og homeroom. Et hold har flere samtidige/sekventielle forløb. At overbelaste Class ville tvinge eleverne til at være "indskrevet" pr. forløb og smadre alle eksisterende multi-tenancy- og ejerskabsqueries.
  • Genbrug Subject som 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 en CourseTopic indskydes senere uden at bryde denne model — Assignment.title flytter 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:

  1. Forretningsreglen er allerede opfyldt strukturelt: GroupAssignment ER afleveringen@@unique([assignmentId, groupId]) findes, og hele submission-/grade-/feedback-kæden (Submission, currentSubmissionId) hænger på GroupAssignment i dag. Intet skal flyttes.
  2. 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.
  3. Lærerens afleveringsoverblik (#7, DONE) aggregerer pr. Assignment — det ville skulle genopfindes.
  4. 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.deadline er sandheden. Det er den, elev-UI'et allerede bruger overalt (StudentAssignment, GroupDetails viser ga.assignment.deadline). Én frist pr. emne — sat i wizard-trin 6.
  • Group.deadline deprecates. Nye flows skriver aldrig til feltet; læsning erstattes af gruppens aktive assignment.deadline.
  • Group.startDate er 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.deadlineassignment.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 deploy i 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 = NULL og vises i en "Uden forløb"-mappe; læreren kan knytte dem til et forløb via redigeringsflowet (FASE C). isShared defaulter til true, 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 courseId på 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.topic lever 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).