feat(aufgaben): Datenmodell und Regeln für Aufgaben und Projekte

Aufgabe mit Typ, Status, Priorität, Aufwand, Fälligkeit und Herkunft;
Projekt als eigene Klammer darum.

Zwei Entwurfsentscheidungen:
- Die Inbox ist kein eigener Zustand, sondern schlicht eine Aufgabe ohne
  Bereich. Einsortieren heißt: einen Bereich setzen. Ein zusätzliches
  Feld könnte mit der Zuordnung auseinanderlaufen.
- Projekt ist ein eigenes Modell, kein Aufgabentyp. Ein Projekt hat
  selbst weder Aufwand noch Fälligkeit, sondern leitet beides aus dem
  ab, was drinsteckt. ARBEITSPAKET und MEILENSTEIN sind Typen der
  Aufgaben *innerhalb* eines Projekts.

@@unique([quelle, quelleRef]) ist die Grundlage für die Integrationen ab
M6: sie upserten darüber, statt zu doppeln. Von Hand erfasste Aufgaben
tragen quelleRef = null; PostgreSQL sieht NULL-Werte als verschieden an,
sie behindern sich also nicht.

Verworfene Aufgaben zählen bewusst gar nicht in den Fortschritt eines
Projekts — sonst sähe es fertig aus, weil die Hälfte gestrichen wurde.

Fälligkeit wird in Kalendertagen in Europe/Berlin gerechnet, nicht in
24-Stunden-Schritten: was heute um 23:00 fällig ist, ist heute fällig.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-10 16:17:41 +02:00
co-authored by Claude Opus 5
parent 704c490368
commit 63c3c15a3c
6 changed files with 1112 additions and 37 deletions
+143 -37
View File
@@ -2,8 +2,8 @@
// Fachbegriffe auf Deutsch, technische Felder auf Englisch.
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
provider = "prisma-client"
output = "../src/generated/prisma"
moduleFormat = "esm"
}
@@ -18,18 +18,18 @@ enum Rolle {
/// Ein Mensch, der sich anmelden darf.
model Benutzer {
id String @id @default(cuid())
email String @unique
name String?
id String @id @default(cuid())
email String @unique
name String?
/// Nur gesetzt, wenn die Anmeldung per Passwort erlaubt ist.
passwortHash String?
rolle Rolle @default(BENUTZER)
aktiv Boolean @default(true)
bildUrl String?
emailGeprueft DateTime?
zuletztAktiv DateTime?
erstelltAm DateTime @default(now())
aktualisiertAm DateTime @updatedAt
passwortHash String?
rolle Rolle @default(BENUTZER)
aktiv Boolean @default(true)
bildUrl String?
emailGeprueft DateTime?
zuletztAktiv DateTime?
erstelltAm DateTime @default(now())
aktualisiertAm DateTime @updatedAt
konten Konto[]
passkeys Passkey[]
@@ -63,19 +63,19 @@ model Konto {
/// Ein registrierter Passkey (WebAuthn) eines Benutzers.
model Passkey {
id String @id @default(cuid())
benutzerId String
id String @id @default(cuid())
benutzerId String
/// Base64URL der Credential-ID.
credentialId String @unique
publicKey Bytes
counter BigInt @default(0)
credentialId String @unique
publicKey Bytes
counter BigInt @default(0)
/// Kommagetrennt, z. B. "internal,hybrid".
transports String?
geraetetyp String?
gesichert Boolean @default(false)
bezeichnung String
erstelltAm DateTime @default(now())
zuletztBenutzt DateTime?
transports String?
geraetetyp String?
gesichert Boolean @default(false)
bezeichnung String
erstelltAm DateTime @default(now())
zuletztBenutzt DateTime?
benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
@@ -98,12 +98,12 @@ model WebauthnChallenge {
/// Lebensbereich mit Farbe, Tageskapazitaet und Arbeitsfenster.
model Bereich {
id String @id @default(cuid())
name String @unique
id String @id @default(cuid())
name String @unique
/// Hex-Farbe inklusive Raute, z. B. "#2563eb".
farbe String
reihenfolge Int @default(0)
aktiv Boolean @default(true)
farbe String
reihenfolge Int @default(0)
aktiv Boolean @default(true)
/// Tageskapazitaet in Minuten, Montag bis Sonntag.
kapazitaetMo Int @default(0)
@@ -121,23 +121,129 @@ model Bereich {
erstelltAm DateTime @default(now())
aktualisiertAm DateTime @updatedAt
aufgaben Aufgabe[]
projekte Projekt[]
@@index([aktiv, reihenfolge])
@@map("bereich")
}
enum AufgabenTyp {
AUFGABE
IDEE
ARBEITSPAKET
MEILENSTEIN
SPIEGEL
}
enum AufgabenStatus {
OFFEN
GEPLANT
WARTET
ERLEDIGT
VERWORFEN
}
/// Woher eine Aufgabe stammt. Integrationen upserten ueber [quelle, quelleRef].
enum Quelle {
MANUELL
ZAMMAD
WARTUNGSPLANER
TELEGRAM
SPIEGEL
MAIL
API
MCP
}
enum ProjektStatus {
AKTIV
PAUSIERT
ABGESCHLOSSEN
VERWORFEN
}
/// Klammer um mehrere Aufgaben. Hat selbst keinen Aufwand und keine
/// Faelligkeit – beides ergibt sich aus den enthaltenen Aufgaben.
model Projekt {
id String @id @default(cuid())
name String
beschreibung String?
status ProjektStatus @default(AKTIV)
bereichId String?
/// Wunschtermin fuer den Abschluss, rein informativ.
zielAm DateTime?
erstelltAm DateTime @default(now())
aktualisiertAm DateTime @updatedAt
bereich Bereich? @relation(fields: [bereichId], references: [id], onDelete: SetNull)
aufgaben Aufgabe[]
@@unique([name])
@@index([status])
@@index([bereichId])
@@map("projekt")
}
/// Eine Aufgabe, Idee, ein Arbeitspaket oder ein Meilenstein.
///
/// Ohne Bereich liegt sie in der Inbox – das ist der einzige Unterschied,
/// "einsortieren" heisst schlicht: einen Bereich setzen.
model Aufgabe {
id String @id @default(cuid())
titel String
notiz String?
typ AufgabenTyp @default(AUFGABE)
status AufgabenStatus @default(OFFEN)
/// 1 = dringend, 5 = irgendwann. 3 ist der Normalfall.
prioritaet Int @default(3)
/// Geschaetzter Aufwand in Minuten; null heisst "noch nicht geschaetzt".
aufwandMinuten Int?
faelligAm DateTime?
erledigtAm DateTime?
bereichId String?
projektId String?
/// Herkunft. Fuer von Hand erfasste Aufgaben MANUELL ohne Verweis.
quelle Quelle @default(MANUELL)
quelleUrl String?
/// Kennung beim Ursprungssystem, z. B. die Ticketnummer.
quelleRef String?
erstelltAm DateTime @default(now())
aktualisiertAm DateTime @updatedAt
bereich Bereich? @relation(fields: [bereichId], references: [id], onDelete: SetNull)
projekt Projekt? @relation(fields: [projektId], references: [id], onDelete: SetNull)
/// Integrationen upserten immer ueber diese beiden Felder. Von Hand
/// erfasste Aufgaben haben quelleRef = null; PostgreSQL sieht NULL-Werte
/// als verschieden an, sie behindern sich also nicht.
@@unique([quelle, quelleRef])
@@index([status, faelligAm])
@@index([bereichId, status])
@@index([projektId])
@@index([typ, status])
@@map("aufgabe")
}
/// Unveraenderlicher Nachweis jeder schreibenden Aktion.
model AuditEintrag {
id String @id @default(cuid())
zeitpunkt DateTime @default(now())
benutzerId String?
id String @id @default(cuid())
zeitpunkt DateTime @default(now())
benutzerId String?
/// Bleibt erhalten, auch wenn der Benutzer spaeter geloescht wird.
benutzerEmail String
aktion String
objektTyp String
objektId String?
aktion String
objektTyp String
objektId String?
/// Nur fachliche Felder, niemals Tokens, Mailinhalte oder Termin-Betreffs.
details Json?
instanz String
details Json?
instanz String
benutzer Benutzer? @relation(fields: [benutzerId], references: [id], onDelete: SetNull)