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
@@ -0,0 +1,80 @@
-- CreateEnum
CREATE TYPE "AufgabenTyp" AS ENUM ('AUFGABE', 'IDEE', 'ARBEITSPAKET', 'MEILENSTEIN', 'SPIEGEL');
-- CreateEnum
CREATE TYPE "AufgabenStatus" AS ENUM ('OFFEN', 'GEPLANT', 'WARTET', 'ERLEDIGT', 'VERWORFEN');
-- CreateEnum
CREATE TYPE "Quelle" AS ENUM ('MANUELL', 'ZAMMAD', 'WARTUNGSPLANER', 'TELEGRAM', 'SPIEGEL', 'MAIL', 'API', 'MCP');
-- CreateEnum
CREATE TYPE "ProjektStatus" AS ENUM ('AKTIV', 'PAUSIERT', 'ABGESCHLOSSEN', 'VERWORFEN');
-- CreateTable
CREATE TABLE "projekt" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"beschreibung" TEXT,
"status" "ProjektStatus" NOT NULL DEFAULT 'AKTIV',
"bereichId" TEXT,
"zielAm" TIMESTAMP(3),
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"aktualisiertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "projekt_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "aufgabe" (
"id" TEXT NOT NULL,
"titel" TEXT NOT NULL,
"notiz" TEXT,
"typ" "AufgabenTyp" NOT NULL DEFAULT 'AUFGABE',
"status" "AufgabenStatus" NOT NULL DEFAULT 'OFFEN',
"prioritaet" INTEGER NOT NULL DEFAULT 3,
"aufwandMinuten" INTEGER,
"faelligAm" TIMESTAMP(3),
"erledigtAm" TIMESTAMP(3),
"bereichId" TEXT,
"projektId" TEXT,
"quelle" "Quelle" NOT NULL DEFAULT 'MANUELL',
"quelleUrl" TEXT,
"quelleRef" TEXT,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"aktualisiertAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "aufgabe_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "projekt_status_idx" ON "projekt"("status");
-- CreateIndex
CREATE INDEX "projekt_bereichId_idx" ON "projekt"("bereichId");
-- CreateIndex
CREATE UNIQUE INDEX "projekt_name_key" ON "projekt"("name");
-- CreateIndex
CREATE INDEX "aufgabe_status_faelligAm_idx" ON "aufgabe"("status", "faelligAm");
-- CreateIndex
CREATE INDEX "aufgabe_bereichId_status_idx" ON "aufgabe"("bereichId", "status");
-- CreateIndex
CREATE INDEX "aufgabe_projektId_idx" ON "aufgabe"("projektId");
-- CreateIndex
CREATE INDEX "aufgabe_typ_status_idx" ON "aufgabe"("typ", "status");
-- CreateIndex
CREATE UNIQUE INDEX "aufgabe_quelle_quelleRef_key" ON "aufgabe"("quelle", "quelleRef");
-- AddForeignKey
ALTER TABLE "projekt" ADD CONSTRAINT "projekt_bereichId_fkey" FOREIGN KEY ("bereichId") REFERENCES "bereich"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "aufgabe" ADD CONSTRAINT "aufgabe_bereichId_fkey" FOREIGN KEY ("bereichId") REFERENCES "bereich"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "aufgabe" ADD CONSTRAINT "aufgabe_projektId_fkey" FOREIGN KEY ("projektId") REFERENCES "projekt"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+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)