chore: Projektbasis und PostgreSQL-Migrationen einrichten

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Paul SchoeneckandCopilot committed 2026-11-03 08:01:24 +01:00
commit 10af6159b4
14 files changed
+2214

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
DATABASE_URL=postgresql://kibu:CHANGE_ME@127.0.0.1:55432/kibu
POSTGRES_PASSWORD=CHANGE_ME
APP_TIMEZONE=Europe/Berlin
SESSION_SECURE=false
# Optional for db:setup; otherwise a random initial password is printed.
BOOTSTRAP_EMAIL=admin@kibu.local
# BOOTSTRAP_PASSWORD=at-least-12-characters
+8
View File
@@ -0,0 +1,8 @@
node_modules/
.next/
.env
.env.*
!.env.example
*.tsbuildinfo
playwright-report/
test-results/
+20
View File
@@ -0,0 +1,20 @@
name: kibu
services:
db:
image: postgres:17-alpine
environment:
POSTGRES_DB: kibu
POSTGRES_USER: kibu
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Run npm run configure first}
ports:
- "127.0.0.1:55432:5432"
volumes:
- kibu_postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U kibu -d kibu"]
interval: 3s
timeout: 3s
retries: 20
restart: unless-stopped
volumes:
kibu_postgres:
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+1788
View File
File diff suppressed because it is too large. Load diff
+33
View File
@@ -0,0 +1,33 @@
{
"name": "kibu",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --hostname 127.0.0.1",
"build": "next build",
"start": "next start --hostname 127.0.0.1",
"configure": "node scripts/configure.mjs",
"db:setup": "tsx scripts/setup.ts",
"db:seed": "tsx scripts/seed-demo.ts",
"test": "tsx --test tests/unit.test.ts",
"test:db": "tsx --test tests/database.test.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"dotenv": "^17.2.0",
"lucide-react": "^0.468.0",
"next": "^16.0.0",
"pg": "^8.16.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"zod": "^4.1.0"
},
"devDependencies": {
"@types/node": "^24.0.0",
"@types/pg": "^8.15.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"tsx": "^4.20.0",
"typescript": "^5.9.0"
}
}
+16
View File
@@ -0,0 +1,16 @@
import { existsSync, writeFileSync } from "node:fs";
import { randomBytes } from "node:crypto";
if (existsSync(".env")) {
console.log(".env existiert bereits und wurde nicht veraendert.");
} else {
const password = randomBytes(24).toString("hex");
writeFileSync(".env", [
`DATABASE_URL=postgresql://kibu:${password}@127.0.0.1:55432/kibu`,
`POSTGRES_PASSWORD=${password}`,
"APP_TIMEZONE=Europe/Berlin",
"SESSION_SECURE=false",
"",
].join("\n"), { flag: "wx" });
console.log(".env mit zufaelligem lokalen Datenbankpasswort erstellt.");
}
+97
View File
@@ -0,0 +1,97 @@
CREATE TABLE pickup_contacts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
child_id uuid NOT NULL REFERENCES children(id) ON DELETE CASCADE,
name text NOT NULL,
relationship text NOT NULL,
phone text NOT NULL DEFAULT '',
authorized boolean NOT NULL DEFAULT true,
notes text NOT NULL DEFAULT ''
);
CREATE INDEX pickup_contacts_child ON pickup_contacts(child_id);
CREATE TABLE alone_rules (
child_id uuid NOT NULL REFERENCES children(id) ON DELETE CASCADE,
weekday integer NOT NULL CHECK (weekday BETWEEN 1 AND 7),
earliest time NOT NULL,
PRIMARY KEY(child_id, weekday)
);
ALTER TABLE attendance DROP CONSTRAINT attendance_pickup_kind_check;
ALTER TABLE attendance ADD CONSTRAINT attendance_pickup_kind_check
CHECK (pickup_kind IN ('planned', 'parents', 'alone'));
ALTER TABLE attendance ADD CONSTRAINT attendance_chronology
CHECK (picked_up_at IS NULL OR picked_up_at >= arrived_at);
CREATE SEQUENCE attendance_revision;
ALTER TABLE attendance ADD COLUMN version bigint NOT NULL DEFAULT nextval('attendance_revision');
CREATE FUNCTION bump_attendance_revision() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
NEW.version := nextval('attendance_revision');
RETURN NEW;
END;
$$;
CREATE TRIGGER attendance_revision_trigger BEFORE UPDATE ON attendance
FOR EACH ROW EXECUTE FUNCTION bump_attendance_revision();
CREATE TABLE audit_log (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
child_id uuid NOT NULL,
entity text NOT NULL,
entity_id text,
day date,
operation text NOT NULL CHECK (operation IN ('INSERT', 'UPDATE', 'DELETE')),
actor_id uuid,
actor_name text NOT NULL,
reason text NOT NULL,
before_data jsonb,
after_data jsonb,
created_at timestamptz NOT NULL DEFAULT clock_timestamp()
);
CREATE INDEX audit_log_child ON audit_log(child_id, id DESC);
CREATE FUNCTION record_child_audit() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
old_data jsonb;
new_data jsonb;
row_data jsonb;
actor uuid;
actor_label text;
BEGIN
IF TG_OP <> 'INSERT' THEN old_data := to_jsonb(OLD); END IF;
IF TG_OP <> 'DELETE' THEN new_data := to_jsonb(NEW); END IF;
IF TG_OP = 'UPDATE' AND old_data - 'version' = new_data - 'version' THEN RETURN NEW; END IF;
row_data := COALESCE(new_data, old_data);
actor := NULLIF(current_setting('kibu.actor_id', true), '')::uuid;
SELECT name || ' (' || email || ')' INTO actor_label FROM staff WHERE id = actor;
INSERT INTO audit_log(child_id, entity, entity_id, day, operation, actor_id, actor_name,
reason, before_data, after_data)
VALUES (
CASE WHEN TG_TABLE_NAME = 'children' THEN (row_data->>'id')::uuid ELSE (row_data->>'child_id')::uuid END,
TG_TABLE_NAME, row_data->>'id', (row_data->>'day')::date, TG_OP, actor,
COALESCE(actor_label, 'Datenbank / Einrichtung'),
COALESCE(NULLIF(current_setting('kibu.reason', true), ''), 'Direkte Datenbankänderung'),
old_data, new_data
);
RETURN COALESCE(NEW, OLD);
END;
$$;
CREATE TRIGGER children_audit AFTER INSERT OR UPDATE OR DELETE ON children FOR EACH ROW EXECUTE FUNCTION record_child_audit();
CREATE TRIGGER weekly_audit AFTER INSERT OR UPDATE OR DELETE ON weekly_schedules FOR EACH ROW EXECUTE FUNCTION record_child_audit();
CREATE TRIGGER override_audit AFTER INSERT OR UPDATE OR DELETE ON day_overrides FOR EACH ROW EXECUTE FUNCTION record_child_audit();
CREATE TRIGGER attendance_audit AFTER INSERT OR UPDATE OR DELETE ON attendance FOR EACH ROW EXECUTE FUNCTION record_child_audit();
CREATE TRIGGER contacts_audit AFTER INSERT OR UPDATE OR DELETE ON pickup_contacts FOR EACH ROW EXECUTE FUNCTION record_child_audit();
CREATE TRIGGER alone_audit AFTER INSERT OR UPDATE OR DELETE ON alone_rules FOR EACH ROW EXECUTE FUNCTION record_child_audit();
CREATE FUNCTION protect_audit_log() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
RAISE EXCEPTION 'Das Audit-Log ist unveraenderlich.';
END;
$$;
CREATE TRIGGER audit_immutable BEFORE UPDATE OR DELETE OR TRUNCATE ON audit_log
FOR EACH STATEMENT EXECUTE FUNCTION protect_audit_log();
INSERT INTO audit_log(child_id, entity, day, operation, actor_name, reason, after_data)
SELECT child_id, 'attendance', day, 'INSERT', 'Datenbank / Einrichtung',
'Bestandsübernahme bei Einführung des Audit-Logs; ursprünglicher Änderungsverlauf nicht verfügbar.',
to_jsonb(a) FROM attendance a;
@@ -0,0 +1,57 @@
CREATE TABLE pickup_slots (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
starts_at time NOT NULL,
ends_at time NOT NULL,
active boolean NOT NULL DEFAULT true,
CHECK (starts_at < ends_at),
UNIQUE(starts_at, ends_at)
);
INSERT INTO pickup_slots(starts_at, ends_at) VALUES
('14:00', '14:15'), ('15:00', '15:15'), ('16:00', '16:15'), ('17:00', '17:15');
ALTER TABLE weekly_schedules ADD COLUMN pickup_end time;
ALTER TABLE weekly_schedules ADD COLUMN pickup_slot_id uuid REFERENCES pickup_slots(id);
ALTER TABLE weekly_schedules ADD CONSTRAINT weekly_pickup_window CHECK(pickup_end IS NULL OR pickup_end > pickup);
ALTER TABLE day_overrides ADD COLUMN pickup_end time;
ALTER TABLE day_overrides ADD COLUMN pickup_slot_id uuid REFERENCES pickup_slots(id);
ALTER TABLE day_overrides ADD CONSTRAINT override_pickup_window CHECK(pickup_end IS NULL OR (attending AND pickup_end > pickup));
UPDATE weekly_schedules w SET pickup_slot_id = s.id, pickup_end = s.ends_at FROM pickup_slots s WHERE w.pickup = s.starts_at;
UPDATE day_overrides o SET pickup_slot_id = s.id, pickup_end = s.ends_at FROM pickup_slots s WHERE o.pickup = s.starts_at;
CREATE TABLE admission_rounds (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
code_hash text NOT NULL UNIQUE,
expires_on date NOT NULL,
status text NOT NULL DEFAULT 'open' CHECK(status IN ('open', 'closed', 'drawn')),
created_by uuid NOT NULL REFERENCES staff(id),
created_at timestamptz NOT NULL DEFAULT now(),
drawn_by uuid REFERENCES staff(id),
drawn_at timestamptz,
places integer CHECK(places > 0),
CHECK ((status = 'drawn' AND drawn_at IS NOT NULL AND drawn_by IS NOT NULL AND places IS NOT NULL)
OR (status <> 'drawn' AND drawn_at IS NULL AND drawn_by IS NULL AND places IS NULL))
);
CREATE TABLE applications (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
round_id uuid NOT NULL REFERENCES admission_rounds(id),
first_name text NOT NULL,
last_name text NOT NULL,
class_name text NOT NULL,
parent_name text NOT NULL,
email text NOT NULL,
notes text NOT NULL DEFAULT '',
status text NOT NULL DEFAULT 'waiting' CHECK(status IN ('waiting', 'selected', 'waitlisted', 'accepted')),
child_id uuid UNIQUE REFERENCES children(id),
submitted_at timestamptz NOT NULL DEFAULT now(),
accepted_at timestamptz,
accepted_by uuid REFERENCES staff(id),
CHECK ((status = 'accepted' AND child_id IS NOT NULL AND accepted_at IS NOT NULL AND accepted_by IS NOT NULL)
OR (status <> 'accepted' AND child_id IS NULL AND accepted_at IS NULL AND accepted_by IS NULL))
);
CREATE UNIQUE INDEX application_unique_child ON applications(round_id, lower(first_name), lower(last_name), lower(email));
CREATE INDEX applications_round ON applications(round_id, submitted_at, id);
CREATE TABLE application_limits (
key_hash text PRIMARY KEY,
attempts integer NOT NULL,
window_start timestamptz NOT NULL
);
+58
View File
@@ -0,0 +1,58 @@
CREATE TABLE IF NOT EXISTS staff (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email text NOT NULL UNIQUE,
name text NOT NULL,
password_hash text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS sessions (
token_hash text PRIMARY KEY,
staff_id uuid NOT NULL REFERENCES staff(id) ON DELETE CASCADE,
expires_at timestamptz NOT NULL
);
CREATE TABLE IF NOT EXISTS login_attempts (
email text PRIMARY KEY,
attempts integer NOT NULL,
window_start timestamptz NOT NULL
);
CREATE TABLE IF NOT EXISTS children (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
first_name text NOT NULL,
last_name text NOT NULL,
class_name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS weekly_schedules (
child_id uuid NOT NULL REFERENCES children(id) ON DELETE CASCADE,
weekday integer NOT NULL CHECK (weekday BETWEEN 1 AND 7),
arrival time NOT NULL,
pickup time NOT NULL,
PRIMARY KEY (child_id, weekday),
CHECK (arrival < pickup)
);
CREATE TABLE IF NOT EXISTS day_overrides (
child_id uuid NOT NULL REFERENCES children(id) ON DELETE CASCADE,
day date NOT NULL,
attending boolean NOT NULL,
arrival time,
pickup time,
PRIMARY KEY (child_id, day),
CHECK ((attending AND arrival IS NOT NULL AND pickup IS NOT NULL AND arrival < pickup)
OR (NOT attending AND arrival IS NULL AND pickup IS NULL))
);
CREATE TABLE IF NOT EXISTS attendance (
child_id uuid NOT NULL REFERENCES children(id) ON DELETE CASCADE,
day date NOT NULL,
status text NOT NULL CHECK (status IN ('arrived', 'sick', 'picked_up')),
arrived_at timestamptz,
picked_up_at timestamptz,
pickup_kind text CHECK (pickup_kind IN ('planned', 'parents')),
updated_by uuid NOT NULL REFERENCES staff(id),
PRIMARY KEY (child_id, day),
CHECK (
(status = 'sick' AND arrived_at IS NULL AND picked_up_at IS NULL AND pickup_kind IS NULL)
OR (status = 'arrived' AND arrived_at IS NOT NULL AND picked_up_at IS NULL AND pickup_kind IS NULL)
OR (status = 'picked_up' AND arrived_at IS NOT NULL AND picked_up_at IS NOT NULL AND pickup_kind IS NOT NULL)
)
);
CREATE INDEX IF NOT EXISTS sessions_expiry ON sessions(expires_at);
+48
View File
@@ -0,0 +1,48 @@
import "dotenv/config";
import { readFile, readdir } from "node:fs/promises";
import { randomBytes } from "node:crypto";
import { db } from "../src/lib/db";
import { hashPassword } from "../src/lib/password";
async function setup() {
if (!process.env.DATABASE_URL) throw new Error("Zuerst npm run configure ausfuehren.");
await db.query(await readFile(new URL("./schema.sql", import.meta.url), "utf8"));
const client = await db.connect();
try {
await client.query("BEGIN");
await client.query("SELECT pg_advisory_xact_lock(724139002)");
await client.query("CREATE TABLE IF NOT EXISTS schema_migrations(name text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())");
const directory = new URL("./migrations/", import.meta.url);
for (const name of (await readdir(directory)).filter((file) => file.endsWith(".sql")).sort()) {
const applied = await client.query("SELECT name FROM schema_migrations WHERE name = $1", [name]);
if (applied.rowCount) continue;
await client.query(await readFile(new URL(name, directory), "utf8"));
await client.query("INSERT INTO schema_migrations(name) VALUES ($1)", [name]);
console.log(`Migration angewendet: ${name}`);
}
await client.query("COMMIT");
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
const email = (process.env.BOOTSTRAP_EMAIL ?? "admin@kibu.local").trim().toLowerCase();
const existing = await db.query("SELECT id FROM staff WHERE email = $1", [email]);
if (existing.rowCount) {
console.log("Schema bereit. Bestehender Zugang bleibt unveraendert.");
return;
}
const password = process.env.BOOTSTRAP_PASSWORD ?? randomBytes(18).toString("base64url");
if (password.length < 12) throw new Error("BOOTSTRAP_PASSWORD muss mindestens 12 Zeichen haben.");
await db.query(
"INSERT INTO staff(email, name, password_hash) VALUES ($1, $2, $3)",
[email, "Betreuung", await hashPassword(password)],
);
console.log(`Erster Zugang erstellt.\nE-Mail: ${email}\nPasswort: ${password}\nBitte sicher aufbewahren.`);
}
setup().catch((error: unknown) => {
console.error("Datenbank-Einrichtung fehlgeschlagen:", error);
process.exitCode = 1;
}).finally(() => db.end());
+16
View File
@@ -0,0 +1,16 @@
import { Pool } from "pg";
const globalDb = globalThis as typeof globalThis & { kibuPool?: Pool };
function createPool() {
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10,
connectionTimeoutMillis: 5000,
});
pool.on("error", (error) => console.error("PostgreSQL-Verbindung im Pool unterbrochen:", error));
return pool;
}
export const db = globalDb.kibuPool ?? createPool();
if (process.env.NODE_ENV !== "production") globalDb.kibuPool = db;
+18
View File
@@ -0,0 +1,18 @@
import { randomBytes, scrypt as scryptCallback, timingSafeEqual } from "node:crypto";
import { promisify } from "node:util";
const scrypt = promisify(scryptCallback);
export async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(16).toString("hex");
const hash = await scrypt(password, salt, 64) as Buffer;
return `${salt}:${hash.toString("hex")}`;
}
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
const [salt, hex] = stored.split(":");
if (!salt || !hex) return false;
const expected = Buffer.from(hex, "hex");
const actual = await scrypt(password, salt, 64) as Buffer;
return expected.length === actual.length && timingSafeEqual(expected, actual);
}
+41
View File
@@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}