feat(betrieb): Health-Endpunkt und Seed für eine frische Datenbank

/api/health meldet Status, Version, Instanz und Erreichbarkeit der
Datenbank – ohne Anmeldung, deshalb ohne Zählstände, Namen oder Adressen.
update.sh wird sich später darauf stützen.

Der Seed legt die vier Bereiche an und ein erstes Verwaltungskonto aus
ADMIN_EMAIL und ADMIN_PASSWORT. Er läuft mehrfach ohne Schaden; ohne
ADMIN_PASSWORT entsteht ein Konto, das sich nur über OIDC oder Passkey
anmelden kann.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-10 13:02:49 +02:00
co-authored by Claude Opus 5
parent e9a2b4b463
commit 1876ae4c6b
4 changed files with 199 additions and 0 deletions
+6
View File
@@ -36,6 +36,12 @@ AUTH_TRUST_HOST=true
# false, damit nur ueber OIDC angemeldet wird. # false, damit nur ueber OIDC angemeldet wird.
ANMELDUNG_PASSWORT=true ANMELDUNG_PASSWORT=true
# Erstes Verwaltungskonto, wird vom Seed angelegt. Ohne ADMIN_PASSWORT
# entsteht ein Konto ohne Passwort, das sich nur per OIDC oder Passkey
# anmelden kann. Nach der Einrichtung dürfen beide Werte wieder raus.
ADMIN_EMAIL=
ADMIN_PASSWORT=
# --- OIDC ------------------------------------------------------------------ # --- OIDC ------------------------------------------------------------------
# firma = Entra ID, daheim = authentik. Leer lassen schaltet OIDC ab. # firma = Entra ID, daheim = authentik. Leer lassen schaltet OIDC ab.
# Der Rueckleit-URI beim Anbieter lautet: # Der Rueckleit-URI beim Anbieter lautet:
+1
View File
@@ -0,0 +1 @@
datum;start;ende;minuten;meilenstein;art;notiz
1 datum start ende minuten meilenstein art notiz
+156
View File
@@ -0,0 +1,156 @@
/**
* Grundausstattung für eine frische Datenbank.
*
* Läuft mehrfach ohne Schaden: alles wird per upsert angelegt, vorhandene
* Einträge bleiben unangetastet. Es entstehen keine erfundenen Aufgaben oder
* Termine – dafür gibt es prisma/demo-seed.ts.
*/
import 'dotenv/config'
import { PrismaPg } from '@prisma/adapter-pg'
import bcrypt from 'bcryptjs'
import { PrismaClient } from '../src/generated/prisma/client.js'
const verbindung = process.env.DATABASE_URL
if (!verbindung) {
throw new Error('DATABASE_URL ist nicht gesetzt.')
}
const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: verbindung }) })
const BEREICHE = [
{
name: 'Firma',
farbe: '#2563eb',
reihenfolge: 1,
arbeitsfensterVon: 7 * 60 + 30,
arbeitsfensterBis: 16 * 60 + 30,
kapazitaetMo: 360,
kapazitaetDi: 360,
kapazitaetMi: 360,
kapazitaetDo: 360,
kapazitaetFr: 240,
kapazitaetSa: 0,
kapazitaetSo: 0,
},
{
name: 'Gewerbe',
farbe: '#7c3aed',
reihenfolge: 2,
arbeitsfensterVon: 17 * 60,
arbeitsfensterBis: 21 * 60,
kapazitaetMo: 90,
kapazitaetDi: 90,
kapazitaetMi: 90,
kapazitaetDo: 90,
kapazitaetFr: 60,
kapazitaetSa: 180,
kapazitaetSo: 0,
},
{
name: 'Privat',
farbe: '#16a34a',
reihenfolge: 3,
arbeitsfensterVon: 17 * 60,
arbeitsfensterBis: 22 * 60,
kapazitaetMo: 60,
kapazitaetDi: 60,
kapazitaetMi: 60,
kapazitaetDo: 60,
kapazitaetFr: 120,
kapazitaetSa: 240,
kapazitaetSo: 240,
},
{
name: 'Homelab',
farbe: '#ea580c',
reihenfolge: 4,
arbeitsfensterVon: 19 * 60,
arbeitsfensterBis: 23 * 60,
kapazitaetMo: 0,
kapazitaetDi: 60,
kapazitaetMi: 0,
kapazitaetDo: 60,
kapazitaetFr: 0,
kapazitaetSa: 120,
kapazitaetSo: 60,
},
]
async function bereicheAnlegen(): Promise<void> {
for (const bereich of BEREICHE) {
await db.bereich.upsert({
where: { name: bereich.name },
update: {},
create: bereich,
})
}
console.log(`Bereiche geprüft: ${BEREICHE.map((b) => b.name).join(', ')}`)
}
async function verwaltungAnlegen(): Promise<void> {
const email = process.env.ADMIN_EMAIL?.trim().toLowerCase()
const passwort = process.env.ADMIN_PASSWORT
if (!email) {
console.log(
'ADMIN_EMAIL ist nicht gesetzt – kein Verwaltungskonto angelegt. ' +
'Bei reiner OIDC-Anmeldung ist das richtig: die erste Person, die sich ' +
'anmeldet, muss danach von Hand hochgestuft werden.',
)
return
}
const vorhanden = await db.benutzer.findUnique({
where: { email },
select: { id: true, rolle: true },
})
if (vorhanden) {
if (vorhanden.rolle !== 'ADMIN') {
await db.benutzer.update({ where: { id: vorhanden.id }, data: { rolle: 'ADMIN' } })
console.log(`${email} zur Verwaltung hochgestuft.`)
} else {
console.log(`${email} ist bereits Verwaltung.`)
}
return
}
if (!passwort) {
await db.benutzer.create({
data: { email, rolle: 'ADMIN', name: null },
})
console.log(
`${email} als Verwaltung angelegt, ohne Passwort. ` +
'Anmeldung ist damit nur über OIDC oder einen Passkey möglich.',
)
return
}
if (passwort.length < 12) {
throw new Error('ADMIN_PASSWORT ist zu kurz, mindestens 12 Zeichen.')
}
await db.benutzer.create({
data: {
email,
rolle: 'ADMIN',
passwortHash: await bcrypt.hash(passwort, 12),
},
})
console.log(`${email} als Verwaltung mit Passwort angelegt.`)
}
async function main(): Promise<void> {
await bereicheAnlegen()
await verwaltungAnlegen()
}
main()
.then(async () => {
await db.$disconnect()
})
.catch(async (fehler: unknown) => {
console.error(fehler)
await db.$disconnect()
process.exit(1)
})
+36
View File
@@ -0,0 +1,36 @@
import { NextResponse } from 'next/server'
import { VERSION } from '@/lib/version'
import { db } from '@/server/db'
import { aktuelleInstanz } from '@/server/instanz'
export const dynamic = 'force-dynamic'
/**
* Auskunft fuer Betrieb und update.sh. Bewusst ohne Anmeldung, deshalb steht
* hier nichts, was nicht ohnehin oeffentlich sein darf – insbesondere keine
* Zaehlstaende, Namen oder Adressen.
*/
export async function GET() {
let datenbank: 'ok' | 'fehler' = 'ok'
try {
await db.$queryRaw`SELECT 1`
} catch {
datenbank = 'fehler'
}
const status = datenbank === 'ok' ? 'ok' : 'fehler'
return NextResponse.json(
{
status,
version: VERSION,
instanz: aktuelleInstanz(),
datenbank,
zeit: new Date().toISOString(),
},
{
status: status === 'ok' ? 200 : 503,
headers: { 'Cache-Control': 'no-store' },
},
)
}