Die Beschreibung sagt auch, was die Schnittstelle bewusst nicht kann — Bereich setzen, Aufgaben loeschen, Zeitbloecke anfassen. Das ist eine Entscheidung und kein Versehen, und wer davorsteht, soll es erfahren, statt es fuer eine Luecke zu halten. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
542 lines
15 KiB
TypeScript
542 lines
15 KiB
TypeScript
/**
|
||
* 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<void> {
|
||
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)),
|
||
},
|
||
})
|
||
}
|
||
|
||
// --- API-Tokens -----------------------------------------------------------
|
||
// Erfundene Hashes: Mit diesen Zeilen laesst sich nichts aufrufen, sie
|
||
// zeigen nur, wie die Liste aussieht.
|
||
await db.apiToken.deleteMany({})
|
||
await db.apiToken.create({
|
||
data: {
|
||
benutzerId: benutzer.id,
|
||
name: 'Wartungsplaner-Import',
|
||
praefix: 'k7m2xq4b',
|
||
hash: 'demo-1',
|
||
rechte: 'erfassen',
|
||
erstelltAm: new Date(Date.UTC(2026, 0, 14, 9, 30)),
|
||
letzteNutzung: new Date(Date.UTC(2026, 2, 17, 5, 12)),
|
||
},
|
||
})
|
||
await db.apiToken.create({
|
||
data: {
|
||
benutzerId: benutzer.id,
|
||
name: 'Auswertung Homelab',
|
||
praefix: 'r3wp9dt4',
|
||
hash: 'demo-2',
|
||
rechte: 'lesen',
|
||
gueltigBis: new Date(Date.UTC(2026, 8, 30, 22, 0)),
|
||
erstelltAm: new Date(Date.UTC(2026, 1, 2, 18, 45)),
|
||
},
|
||
})
|
||
await db.apiToken.create({
|
||
data: {
|
||
benutzerId: benutzer.id,
|
||
name: 'Altes Skript',
|
||
praefix: 'z8n4vt6c',
|
||
hash: 'demo-3',
|
||
rechte: 'lesen,schreiben',
|
||
erstelltAm: new Date(Date.UTC(2025, 10, 3, 11, 0)),
|
||
widerrufenAm: new Date(Date.UTC(2026, 1, 20, 8, 15)),
|
||
},
|
||
})
|
||
|
||
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)
|
||
})
|