Initial commit: Kalendartool (Next.js, Prisma, Docker)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Kühn
2026-10-08 14:38:04 +02:00
commit bc49c3074e
94 changed files with 12812 additions and 0 deletions

View File

@@ -0,0 +1,100 @@
-- Initiale Migration (manuell gepflegt, damit `prisma migrate deploy`
-- im Docker-Container ohne Datenbankzugriff zur Build-Zeit funktioniert).
-- Erzeugt aus prisma/schema.prisma mit: npx prisma migrate dev --name init
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('ADMIN', 'USER');
CREATE TYPE "ReservationStatus" AS ENUM ('CONFIRMED', 'CANCELLED');
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password_hash" TEXT NOT NULL,
"role" "Role" NOT NULL DEFAULT 'USER',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "calendars" (
"id" TEXT NOT NULL,
"owner_id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"timezone" TEXT NOT NULL DEFAULT 'Europe/Berlin',
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "calendars_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "calendar_members" (
"id" TEXT NOT NULL,
"calendar_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "calendar_members_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "reservations" (
"id" TEXT NOT NULL,
"calendar_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"start_at" TIMESTAMP(3) NOT NULL,
"end_at" TIMESTAMP(3) NOT NULL,
"status" "ReservationStatus" NOT NULL DEFAULT 'CONFIRMED',
"notes" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "reservations_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "invites" (
"id" TEXT NOT NULL,
"calendar_id" TEXT NOT NULL,
"token_hash" TEXT NOT NULL,
"expires_at" TIMESTAMP(3),
"used_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "invites_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
CREATE UNIQUE INDEX "calendar_members_user_id_key" ON "calendar_members"("user_id");
CREATE INDEX "calendars_owner_id_idx" ON "calendars"("owner_id");
CREATE UNIQUE INDEX "calendar_members_calendar_id_user_id_key" ON "calendar_members"("calendar_id", "user_id");
CREATE INDEX "calendar_members_calendar_id_idx" ON "calendar_members"("calendar_id");
CREATE INDEX "reservations_calendar_id_start_at_idx" ON "reservations"("calendar_id", "start_at");
CREATE INDEX "reservations_user_id_idx" ON "reservations"("user_id");
CREATE UNIQUE INDEX "invites_token_hash_key" ON "invites"("token_hash");
CREATE INDEX "invites_calendar_id_idx" ON "invites"("calendar_id");
-- AddForeignKey
ALTER TABLE "calendars" ADD CONSTRAINT "calendars_owner_id_fkey" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "calendar_members" ADD CONSTRAINT "calendar_members_calendar_id_fkey" FOREIGN KEY ("calendar_id") REFERENCES "calendars"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "calendar_members" ADD CONSTRAINT "calendar_members_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "reservations" ADD CONSTRAINT "reservations_calendar_id_fkey" FOREIGN KEY ("calendar_id") REFERENCES "calendars"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "reservations" ADD CONSTRAINT "reservations_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "invites" ADD CONSTRAINT "invites_calendar_id_fkey" FOREIGN KEY ("calendar_id") REFERENCES "calendars"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,14 @@
-- Migration: Kalender-Einstellungen fuer Reservierungsregeln.
-- max_reservation_minutes: maximale Reservierungsdauer in Minuten
-- (15..1440); 0 bedeutet unbegrenzt. Default 240 (4 Stunden).
-- show_title_to_members: duerfen Nicht-Admins die Titel fremder
-- Reservierungen sehen? Default true (bisheriges Verhalten).
-- show_notes_to_members: duerfen Nicht-Admins die Notizen fremder
-- Reservierungen sehen? Default false (bisheriges Verhalten).
-- AlterTable
ALTER TABLE "calendars" ADD COLUMN "max_reservation_minutes" INTEGER NOT NULL DEFAULT 240;
ALTER TABLE "calendars" ADD COLUMN "show_title_to_members" BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE "calendars" ADD COLUMN "show_notes_to_members" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,10 @@
-- Migration: Username-Spalte fuer Benutzer.
-- username: frei waehlbarer Anzeigename (optional, eindeutig).
-- Im Profil-Badge wird username bevorzugt, sonst email angezeigt.
-- Bestehende Benutzer erhalten NULL; sie koennen den Namen im Profil setzen.
-- AlterTable
ALTER TABLE "users" ADD COLUMN "username" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "users_username_key" ON "users"("username");

View File

@@ -0,0 +1,12 @@
-- Migration: Case-insensitive Eindeutigkeit fuer Benutzernamen.
-- Bisher war nur "Anna" != "anna" geschuetzt (case-sensitiver Unique-
-- Index). Beide Varianten konnten gleichzeitig existieren und im
-- Profil-Badge/Reservierungslisten als Identitaet verwechselt werden.
-- Der neue funktionale Unique-Index auf lower(username) ist strenger
-- und deckt den alten Fall mit ab.
-- DropIndex
DROP INDEX "users_username_key";
-- CreateIndex (funktional; von Prisma-Introspektion nicht abbildbar)
CREATE UNIQUE INDEX "users_username_lower_key" ON "users"(lower("username"));

View File

@@ -0,0 +1,5 @@
-- Fügt die hub_id-Spalte hinzu: stabile User-ID der MultiToolApp-Plattform.
-- null = rein lokaler Account; unique verhindert doppelte Kopplung.
ALTER TABLE "users" ADD COLUMN "hub_id" TEXT;
CREATE UNIQUE INDEX "users_hub_id_key" ON "users"("hub_id");

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

132
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,132 @@
// Prisma-Schema fuer das Kalender- und Reservierungstool.
// Kernregeln aus plan.md:
// USER -> genau 1 Kalender (ueber calendar_members, unique auf user_id)
// ADMIN -> 0..n Kalender (owner_id)
// Invite-Token wird nur gehasht gespeichert.
generator client {
provider = "prisma-client-js"
// linux-musl-openssl-3.0.x: Alpine-Container (node:20-alpine) nutzen
// OpenSSL 3; ohne dieses Target waehlt Prisma die 1.1-Engine, die
// dort zur Laufzeit nicht ladbar ist.
binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
/// Rollen nach plan.md Abschnitt 10: nur ADMIN und USER zum Start.
enum Role {
ADMIN
USER
}
/// Status einer Reservierung.
enum ReservationStatus {
CONFIRMED
CANCELLED
}
model User {
id String @id @default(cuid())
email String @unique
/// Frei waehlbarer Anzeigename; null, wenn noch nicht gesetzt.
/// Im Profil-Badge wird username bevorzugt, sonst email angezeigt.
username String? @unique
passwordHash String @map("password_hash")
role Role @default(USER)
/// Stabile User-ID der MultiToolApp-Plattform (SSO via Hub-Token).
/// null = rein lokaler Account (klassisches Login). Beim ersten
/// Hub-Login wird der Account an die Hub-ID gekoppelt (JIT-Provisioning).
hubId String? @unique @map("hub_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
/// Kalender, deren Mitglied dieser User ist.
/// Durch @unique ist sichergestellt: Ein User gehoert GENAU EINEM Kalender an.
memberships CalendarMember[]
/// Kalender, die dieser User besitzt (nur fuer ADMIN relevant).
ownedCalendars Calendar[] @relation("CalendarOwner")
reservations Reservation[]
@@map("users")
}
model Calendar {
id String @id @default(cuid())
ownerId String @map("owner_id")
title String
description String?
timezone String @default("Europe/Berlin")
isActive Boolean @default(true) @map("is_active")
/// Maximale Reservierungsdauer in Minuten (15..1440). 0 = unbegrenzt.
maxReservationMinutes Int @default(240) @map("max_reservation_minutes")
/// Duerven Nicht-Admins die Titel fremder Reservierungen sehen?
showTitleToMembers Boolean @default(true) @map("show_title_to_members")
/// Duerven Nicht-Admins die Notizen fremder Reservierungen sehen?
showNotesToMembers Boolean @default(false) @map("show_notes_to_members")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
owner User @relation("CalendarOwner", fields: [ownerId], references: [id], onDelete: Cascade)
members CalendarMember[]
invites Invite[]
reservations Reservation[]
@@index([ownerId])
@@map("calendars")
}
/// Zuordnung User <-> Kalender.
/// Das unique auf user_id erzwingt die Kernregel: ein User, genau ein Kalender.
model CalendarMember {
id String @id @default(cuid())
calendarId String @map("calendar_id")
userId String @unique @map("user_id")
createdAt DateTime @default(now()) @map("created_at")
calendar Calendar @relation(fields: [calendarId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([calendarId, userId])
@@index([calendarId])
@@map("calendar_members")
}
model Reservation {
id String @id @default(cuid())
calendarId String @map("calendar_id")
userId String @map("user_id")
title String
startAt DateTime @map("start_at")
endAt DateTime @map("end_at")
status ReservationStatus @default(CONFIRMED)
notes String?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
calendar Calendar @relation(fields: [calendarId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([calendarId, startAt])
@@index([userId])
@@map("reservations")
}
model Invite {
id String @id @default(cuid())
calendarId String @map("calendar_id")
tokenHash String @unique @map("token_hash")
expiresAt DateTime? @map("expires_at")
usedAt DateTime? @map("used_at")
createdAt DateTime @default(now()) @map("created_at")
calendar Calendar @relation(fields: [calendarId], references: [id], onDelete: Cascade)
@@index([calendarId])
@@map("invites")
}

78
prisma/seed-admin.cjs Normal file
View File

@@ -0,0 +1,78 @@
/**
* Seed-Script: Legt einen Admin-Account an (First-Run-Setup).
*
* Wird beim Container-Start ausgefuehrt (nach migrate deploy, vor server.js)
* und kann auch manuell aufgerufen werden:
*
* docker compose exec app node prisma/seed-admin.cjs admin@example.com "Passwort10"
*
* Verhalten (nicht-destruktiv, wie bei Gitea/Vaultwarden):
* - Wenn bereits ein Admin existiert: Abbruch mit Hinweis (nichts geloescht).
* - Wenn noch kein Admin existiert: Legt genau einen an.
* - Bestehende User werden NIEMALS geloescht.
* - Wenn SEED_ADMIN_* nicht gesetzt sind: laeuft ohne Nebeneffekte durch.
*
* Credentials werden aus Env-Variablen oder CLI-Argumenten gelesen
* (CLI-Argumente haben Vorrang).
*/
const { PrismaClient } = require('@prisma/client');
const bcrypt = require('bcryptjs');
const prisma = new PrismaClient();
async function seedAdmin() {
// CLI-Argumente haben Vorrang vor Env-Variablen.
const email = (process.argv[2] || process.env.SEED_ADMIN_EMAIL || '').trim();
const password = (process.argv[3] || process.env.SEED_ADMIN_PASSWORD || '').trim();
if (!email || !password) {
console.error(
'[seed] Verwendung: node prisma/seed-admin.cjs <email> <passwort>\n' +
' oder: SEED_ADMIN_EMAIL=… SEED_ADMIN_PASSWORD=… node prisma/seed-admin.cjs',
);
process.exitCode = 1;
return;
}
if (password.length < 10) {
console.error('[seed] Passwort muss mindestens 10 Zeichen lang sein.');
process.exitCode = 1;
return;
}
// First-Run-Detection: Wenn schon ein Admin existiert, nicht anlegen.
const adminCount = await prisma.user.count({ where: { role: 'ADMIN' } });
if (adminCount > 0) {
console.log('[seed] Es existiert bereits ein Admin-Account. Abbruch (nicht-destruktiv).');
return;
}
// Pruefen, ob die E-Mail bereits von einem normalen User belegt ist.
const existing = await prisma.user.findUnique({ where: { email: email } });
if (existing) {
console.error('[seed] Ein User mit dieser E-Mail existiert bereits (als USER).');
process.exitCode = 1;
return;
}
const passwordHash = await bcrypt.hash(password, 12);
await prisma.user.create({
data: {
email: email,
passwordHash: passwordHash,
role: 'ADMIN',
},
});
console.log('[seed] Admin-Account "' + email + '" erfolgreich angelegt.');
}
seedAdmin()
.catch(function (error) {
console.error('[seed] Fehler:', error);
process.exitCode = 1;
})
.finally(async function () {
await prisma.$disconnect();
});