/** * Daten für die Screenshot-Instanz. * * Alles hier ist frei erfunden. Diese Datei darf niemals gegen eine * Datenbank mit echten Daten laufen — deshalb bricht sie ab, wenn * DEMO_MODUS nicht ausdrücklich auf true steht. * * Aufruf: über npm run screenshots, nicht von Hand. */ import 'dotenv/config' import { PrismaPg } from '@prisma/adapter-pg' import bcrypt from 'bcryptjs' import { PrismaClient } from '../src/generated/prisma/client.js' if (process.env.DEMO_MODUS !== 'true') { console.error( 'Der Demo-Seed läuft nur mit DEMO_MODUS=true. Das ist Absicht: er würde ' + 'sonst erfundene Daten in eine echte Datenbank schreiben.', ) process.exit(1) } const verbindung = process.env.DATABASE_URL if (!verbindung) { console.error('DATABASE_URL ist nicht gesetzt.') process.exit(1) } const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: verbindung }) }) export const DEMO_EMAIL = 'martina.beispiel@planer.invalid' export const DEMO_PASSWORT = 'demo-instanz-nur-fuer-bilder' const BEREICHE = [ { name: 'Firma', farbe: '#2563eb', reihenfolge: 1, arbeitsfensterVon: 450, arbeitsfensterBis: 990, kapazitaetMo: 360, kapazitaetDi: 360, kapazitaetMi: 360, kapazitaetDo: 360, kapazitaetFr: 240, kapazitaetSa: 0, kapazitaetSo: 0, }, { name: 'Gewerbe', farbe: '#7c3aed', reihenfolge: 2, arbeitsfensterVon: 1020, arbeitsfensterBis: 1260, kapazitaetMo: 90, kapazitaetDi: 90, kapazitaetMi: 90, kapazitaetDo: 90, kapazitaetFr: 60, kapazitaetSa: 180, kapazitaetSo: 0, }, { name: 'Privat', farbe: '#16a34a', reihenfolge: 3, arbeitsfensterVon: 1020, arbeitsfensterBis: 1320, kapazitaetMo: 60, kapazitaetDi: 60, kapazitaetMi: 60, kapazitaetDo: 60, kapazitaetFr: 120, kapazitaetSa: 240, kapazitaetSo: 240, }, { name: 'Homelab', farbe: '#ea580c', reihenfolge: 4, arbeitsfensterVon: 1140, arbeitsfensterBis: 1380, kapazitaetMo: 0, kapazitaetDi: 60, kapazitaetMi: 0, kapazitaetDo: 60, kapazitaetFr: 0, kapazitaetSa: 120, kapazitaetSo: 60, }, ] /** Erfundene Protokolleinträge, damit die Protokollseite nicht leer wirkt. */ const PROTOKOLL = [ { aktion: 'bereich.angelegt', objektTyp: 'Bereich', details: { name: 'Homelab' } }, { aktion: 'bereich.geaendert', objektTyp: 'Bereich', details: { vorher: 'Nebenerwerb', nachher: 'Gewerbe' }, }, { aktion: 'passkey.angelegt', objektTyp: 'Passkey', details: { bezeichnung: 'Diensthandy' }, }, ] /** * Erfundene Aufgaben. Die Fälligkeit steht als Versatz in Tagen zum festen * Demo-Zeitpunkt, damit die Bilder immer dieselbe Mischung aus überfällig, * heute und später zeigen. */ const AUFGABEN = [ // Überfällig { titel: 'Angebot Heizungstausch nachfassen', bereich: 'Gewerbe', versatz: -3, prioritaet: 2, aufwand: 30, }, { titel: 'Rechnung 2026-114 korrigieren', bereich: 'Gewerbe', versatz: -1, prioritaet: 1, aufwand: 20, }, // Heute und morgen { titel: 'Wochenbericht an die Geschäftsführung', bereich: 'Firma', versatz: 0, prioritaet: 2, aufwand: 45, }, { titel: 'Rückruf Frau Sonnenberg', bereich: 'Firma', versatz: 0, prioritaet: 1, aufwand: 15, }, { titel: 'Zahnarzttermin bestätigen', bereich: 'Privat', versatz: 1, prioritaet: 3, aufwand: 10, }, // Später { titel: 'Backup-Strategie für den Server überdenken', bereich: 'Homelab', versatz: 9, prioritaet: 4, aufwand: 120, }, { titel: 'Reifen wechseln lassen', bereich: 'Privat', versatz: 14, prioritaet: 3, aufwand: 60, }, // Ohne Termin { titel: 'Ablage im Keller sortieren', bereich: 'Privat', versatz: null, prioritaet: 5, aufwand: 180, }, // Erledigt { titel: 'Steuerunterlagen zusammenstellen', bereich: 'Gewerbe', versatz: -5, prioritaet: 3, aufwand: 90, status: 'ERLEDIGT' as const, }, ] /** Liegt noch in der Inbox – ohne Bereich. */ const INBOX = [ { titel: 'Sicherungskasten beschriften' }, { titel: 'Angebot Netzwerkschrank einholen' }, ] const IDEEN = [ { titel: 'Newsletter für Bestandskunden' }, { titel: 'Werkstatt-Podcast aufnehmen' }, { titel: 'Regale im Lager neu beschriften' }, ] const PROJEKT_AUFGABEN = [ { titel: 'Anforderungen sammeln', typ: 'ARBEITSPAKET' as const, status: 'ERLEDIGT' as const, }, { titel: 'Entwurf abstimmen', typ: 'ARBEITSPAKET' as const, status: 'ERLEDIGT' as const, }, { titel: 'Texte schreiben', typ: 'ARBEITSPAKET' as const, status: 'OFFEN' as const }, { titel: 'Fotos beauftragen', typ: 'ARBEITSPAKET' as const, status: 'WARTET' as const }, { titel: 'Seite ist online', typ: 'MEILENSTEIN' as const, status: 'OFFEN' as const }, ] /** Fester Bezugspunkt, passend zu src/server/zeitgeber.ts. */ const DEMO_JETZT = new Date('2026-03-17T08:20:00.000Z') function tageVersetzt(tage: number): Date { return new Date(DEMO_JETZT.getTime() + tage * 24 * 60 * 60 * 1000) } async function main(): Promise { console.log('Demo-Daten einspielen.') for (const bereich of BEREICHE) { await db.bereich.upsert({ where: { name: bereich.name }, update: bereich, create: bereich, }) } const benutzer = await db.benutzer.upsert({ where: { email: DEMO_EMAIL }, update: { rolle: 'ADMIN', name: 'Martina Beispiel' }, create: { email: DEMO_EMAIL, name: 'Martina Beispiel', rolle: 'ADMIN', passwortHash: await bcrypt.hash(DEMO_PASSWORT, 10), }, }) await db.auditEintrag.deleteMany({}) // Feste Zeitpunkte, damit die Bilder zwischen zwei Läufen gleich bleiben. let versatz = 0 for (const eintrag of PROTOKOLL) { versatz += 37 await db.auditEintrag.create({ data: { zeitpunkt: new Date(Date.UTC(2026, 2, 17, 9, versatz % 60)), benutzerId: benutzer.id, benutzerEmail: benutzer.email, aktion: eintrag.aktion, objektTyp: eintrag.objektTyp, details: eintrag.details, instanz: 'daheim', }, }) } await db.passkey.deleteMany({ where: { benutzerId: benutzer.id } }) await db.passkey.create({ data: { benutzerId: benutzer.id, credentialId: 'demo-passkey-diensthandy', publicKey: Buffer.from([0]), bezeichnung: 'Diensthandy', gesichert: true, erstelltAm: new Date(Date.UTC(2026, 1, 3, 8, 15)), zuletztBenutzt: new Date(Date.UTC(2026, 2, 17, 7, 42)), }, }) // --- Aufgaben, Ideen und ein Projekt ------------------------------------- await db.aufgabe.deleteMany({}) await db.projekt.deleteMany({}) const bereichsKennung = new Map( (await db.bereich.findMany({ select: { id: true, name: true } })).map((b) => [ b.name, b.id, ]), ) for (const eintrag of AUFGABEN) { await db.aufgabe.create({ data: { titel: eintrag.titel, bereichId: bereichsKennung.get(eintrag.bereich) ?? null, prioritaet: eintrag.prioritaet, aufwandMinuten: eintrag.aufwand, faelligAm: eintrag.versatz === null ? null : tageVersetzt(eintrag.versatz), status: eintrag.status ?? 'OFFEN', erledigtAm: eintrag.status === 'ERLEDIGT' ? tageVersetzt(-4) : null, }, }) } for (const eintrag of INBOX) { await db.aufgabe.create({ data: { titel: eintrag.titel } }) } for (const eintrag of IDEEN) { await db.aufgabe.create({ data: { titel: eintrag.titel, typ: 'IDEE' } }) } const projekt = await db.projekt.create({ data: { name: 'Website neu aufsetzen', beschreibung: 'Der alte Auftritt ist von 2019 und auf dem Handy kaum lesbar. Fertig ist es, wenn die neue Seite online ist und das Kontaktformular ankommt.', bereichId: bereichsKennung.get('Gewerbe') ?? null, zielAm: tageVersetzt(45), }, }) for (const eintrag of PROJEKT_AUFGABEN) { await db.aufgabe.create({ data: { titel: eintrag.titel, typ: eintrag.typ, status: eintrag.status, projektId: projekt.id, bereichId: bereichsKennung.get('Gewerbe') ?? null, aufwandMinuten: eintrag.typ === 'MEILENSTEIN' ? null : 120, erledigtAm: eintrag.status === 'ERLEDIGT' ? tageVersetzt(-7) : null, }, }) } // --- Kalenderkonto und erfundene Termine --------------------------------- // Die Tokens sind absichtlich Unsinn: die Screenshot-Instanz spricht mit // keinem Anbieter, sie soll die Seite nur nicht leer zeigen. await db.kalenderkonto.deleteMany({}) const kalenderkonto = await db.kalenderkonto.create({ data: { benutzerId: benutzer.id, anbieter: 'GRAPH', anzeigename: 'Firma Outlook', extKontoId: 'martina.beispiel@firma.invalid', letzterAbgleich: new Date(DEMO_JETZT.getTime() - 6 * 60_000), }, }) const TERMINE = [ { betreff: 'Wochenbesprechung', vonStunde: 8, bisStunde: 9 }, { betreff: 'Kundengespräch Sonnenberg', vonStunde: 10, bisStunde: 11 }, { betreff: 'Mittagspause', vonStunde: 11, bisStunde: 12, frei: true }, { betreff: 'Abnahme Baustelle', vonStunde: 13, bisStunde: 15 }, ] for (const eintrag of TERMINE) { await db.termin.create({ data: { kalenderkontoId: kalenderkonto.id, extId: `demo-${eintrag.betreff.toLowerCase().replace(/[^a-z]+/g, '-')}`, betreff: eintrag.betreff, beginn: new Date(Date.UTC(2026, 2, 17, eintrag.vonStunde - 1, 0)), ende: new Date(Date.UTC(2026, 2, 17, eintrag.bisStunde - 1, 0)), zeigtAls: eintrag.frei ? 'FREI' : 'GEBUCHT', }, }) } await db.termin.create({ data: { kalenderkontoId: kalenderkonto.id, extId: 'demo-brueckentag', betreff: 'Brückentag', beginn: new Date(Date.UTC(2026, 2, 20, 0, 0)), ende: new Date(Date.UTC(2026, 2, 21, 0, 0)), ganztaegig: true, zeigtAls: 'ABWESEND', }, }) // --- Zeitblöcke: eingeplante Aufgaben im Kalender ------------------------- await db.zeitblock.deleteMany({}) const einzuplanen = await db.aufgabe.findMany({ where: { titel: { in: [ 'Wochenbericht an die Geschäftsführung', 'Angebot Heizungstausch nachfassen', ], }, }, select: { id: true, titel: true, aufwandMinuten: true }, }) let blockStunde = 15 for (const aufgabe of einzuplanen) { const beginn = new Date(Date.UTC(2026, 2, 17, blockStunde - 1, 0)) await db.zeitblock.create({ data: { aufgabeId: aufgabe.id, kalenderkontoId: kalenderkonto.id, beginn, ende: new Date(beginn.getTime() + (aufgabe.aufwandMinuten ?? 60) * 60_000), extId: `demo-block-${aufgabe.id}`, hash: 'demo', }, }) await db.aufgabe.update({ where: { id: aufgabe.id }, data: { status: 'GEPLANT' } }) blockStunde += 2 } // Eine Aufgabe, die von außen kam – zeigt die Herkunft in der Liste. await db.aufgabe.create({ data: { titel: 'Störung Klimagerät Serverraum prüfen', bereichId: bereichsKennung.get('Firma') ?? null, prioritaet: 2, aufwandMinuten: 60, faelligAm: tageVersetzt(2), quelle: 'ZAMMAD', quelleRef: 'demo-38421', quelleUrl: 'https://zammad.example.invalid/ticket/38421', }, }) // --- Benachrichtigungen --------------------------------------------------- // Für die Bilder eingeschaltet. Eine leere Karte zeigt nichts von dem, // was das Kapitel erklärt. await db.benachrichtigungseinstellung.upsert({ where: { benutzerId: benutzer.id }, update: {}, create: { benutzerId: benutzer.id, anlaesse: 'tagesueberblick,faelligkeit,zeitblock', perPush: true, perMail: true, perTelegram: true, ueberblickUm: 7 * 60, ruheVon: 20 * 60, ruheBis: 7 * 60, ruheAmWochenende: false, zeitblockVorlaufMinuten: 10, }, }) // Kein Push-Gerät in den Demo-Daten: Seine Schlüssel liegen // verschlüsselt, und der Versand entfernt jedes Gerät, dessen Schlüssel er // nicht lesen kann. Ein erfundener Eintrag verschwände beim ersten Lauf // wieder — die Bilder zeigen deshalb den Zustand vor der ersten Anmeldung. // --- Integrationen -------------------------------------------------------- await db.integrationszustand.deleteMany({}) await db.integrationszustand.create({ data: { quelle: 'TELEGRAM', merker: '4711', letzterLauf: new Date(Date.UTC(2026, 2, 17, 8, 18)), letzterErfolg: new Date(Date.UTC(2026, 2, 17, 8, 18)), }, }) await db.integrationszustand.create({ data: { quelle: 'SPIEGEL', letzterLauf: new Date(Date.UTC(2026, 2, 17, 8, 15)), letzterErfolg: new Date(Date.UTC(2026, 2, 17, 8, 15)), }, }) await db.telegramChat.deleteMany({}) await db.telegramChat.create({ data: { // Erfunden. Der Bot ist ohnehin abgeschaltet, solange DEMO_MODUS gilt. chatId: BigInt(147258369), benutzerId: benutzer.id, bezeichnung: 'Martina', erstelltAm: new Date(Date.UTC(2026, 1, 3, 8, 25)), zuletztBenutzt: new Date(Date.UTC(2026, 2, 17, 6, 40)), }, }) // Dienstliche Belegung, wie sie aus der Firma hereinkaeme: ohne Titel. await db.spiegelbelegung.deleteMany({}) for (const [tag, stunde, dauer] of [ [17, 9, 2], [18, 13, 3], [19, 8, 1], ] as const) { await db.spiegelbelegung.create({ data: { quelleRef: `demo-spiegel-${tag}-${stunde}`, beginn: new Date(Date.UTC(2026, 2, tag, stunde - 1, 0)), ende: new Date(Date.UTC(2026, 2, tag, stunde - 1 + dauer, 0)), }, }) } console.log(`Demo-Daten stehen bereit. Anmeldung: ${DEMO_EMAIL}`) } main() .then(async () => { await db.$disconnect() }) .catch(async (fehler: unknown) => { console.error(fehler) await db.$disconnect() process.exit(1) })