Die Wegwerf-Instanz bekommt je Lauf ein frisch erzeugtes VAPID-Paar und einen Mailhost, den es nie geben wird. Damit zeigen die Bilder den eingerichteten Normalfall statt zweier Warnhinweise — und im Verzeichnis bleibt kein privater Schluessel liegen. Kein Push-Geraet mehr in den Demo-Daten: Seine Schluessel liegen verschluesselt, und der Versand entfernt jedes Geraet, dessen Schluessel er nicht lesen kann. Der erfundene Eintrag waere beim ersten Lauf wieder verschwunden. Dass der Browser im Bild "blockiert" meldet, laesst sich nicht abstellen: Chromium im Kopflosbetrieb antwortet auf Notification.permission immer so. Das Handbuch sagt es jetzt dazu. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
457 lines
12 KiB
TypeScript
457 lines
12 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,
|
||
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.
|
||
|
||
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)
|
||
})
|