Files
everythingbutwork/packages/db/src/seed.ts
T
dejanandClaude Opus 4.8 864a1cef6a
ci / quality (push) Failing after 1m0s
ci / build (push) Has been skipped
feat(projekt): Projektmanagement-Tiefe nach Buch & Leitfaden v3.0
Operationalisiert den ToolCTRL-Projektmanagement-Leitfaden auf der
Projekt-Detailseite (hybrid: Cockpit-Primitive nativ, Schwergewichtiges
per Outline-Link):

- Projekt-Modus (Relax/Standard/Eskalation/Taskforce) + Dashboard-Surfacing
- Risiko- & Chancen-Register (Wahrscheinlichkeit×Auswirkung, Showstopper,
  Owner, Strategie) — offene Showstopper landen auf dem Dashboard
- Stakeholder-Analyse mit Macht/Interesse-Matrix
- Ziel & Definition: SMART-Ziel, Nicht-Ziele, Liefergegenstaende, Maxime,
  Zieldreieck (welche Ecke gibt nach)
- Meilensteine editierbar inkl. Ergebnis/Kundennutzen, Freitags-/
  Feiertags-Warnung, Mindest-2-Hinweis
- Phasen-Lebenszyklus (Definition→Planung→Umsetzung→Abschluss) mit Gate-Reviews
- Projekt-Statusbericht (Ampel/Trend/Text, extern-Flag, Verlauf) — speist
  statusCache & Pflicht-Minimum
- Änderungs-Log (Scope-Creep-Schutz), strukturierter Abschluss mit
  Checkliste + Abschlussvermerk

Migrationen 0009_pm_insights + 0010_leitfaden_phases. Enthält auch die
gemeinsame schema.ts (inkl. Lead-Tabellen aus dem Vor-Commit) und Seed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 17:31:30 +02:00

606 lines
22 KiB
TypeScript

// Seed-Skript: idempotent. Legt Areas, Test-Mitarbeiter (inkl. Admin Dejan) und
// Dummy-Daten (Projekte, Deals, Kampagnen, Tageseinträge, Blocker) an.
import { sql, eq } from 'drizzle-orm';
import { createDbClient } from './client.js';
import {
area,
user,
project,
milestone,
deal,
campaign,
leadHandover,
gtmStep,
dayEntry,
todo,
blocker,
lead,
leadContact,
leadTouchpoint,
risk,
stakeholder,
projectStatus,
changeEntry,
} from './schema.js';
const AREAS = [
{ slug: 'entwicklung', name: 'Entwicklung', kind: 'dev' as const, order: 1 },
{ slug: 'vertrieb', name: 'Vertrieb', kind: 'sales' as const, order: 2 },
{ slug: 'marketing', name: 'Marketing', kind: 'marketing' as const, order: 3 },
{ slug: 'allgemein', name: 'Allgemein', kind: 'allgemein' as const, order: 99 },
];
const USERS = [
{
authentikSub: 'local-dejan',
name: 'Dejan Cosic',
email: 'dejan@tool-ctrl.de',
isAdmin: true,
defaultAreaSlug: 'entwicklung',
},
{
authentikSub: 'local-lukas',
name: 'Lukas Berger',
email: 'lukas@tool-ctrl.de',
isAdmin: false,
defaultAreaSlug: 'entwicklung',
},
{
authentikSub: 'local-anna',
name: 'Anna Heinrich',
email: 'anna@tool-ctrl.de',
isAdmin: false,
defaultAreaSlug: 'vertrieb',
},
{
authentikSub: 'local-marco',
name: 'Marco Diaz',
email: 'marco@tool-ctrl.de',
isAdmin: false,
defaultAreaSlug: 'marketing',
},
{
authentikSub: 'local-jana',
name: 'Jana Müller',
email: 'jana@tool-ctrl.de',
isAdmin: false,
defaultAreaSlug: 'allgemein',
},
];
function isoDateOffset(days: number): string {
const d = new Date();
d.setDate(d.getDate() + days);
return d.toISOString().slice(0, 10);
}
function pickWerktag(daysBack: number): string {
const cur = new Date();
cur.setDate(cur.getDate() - daysBack);
while (cur.getUTCDay() === 0 || cur.getUTCDay() === 6) {
cur.setDate(cur.getDate() - 1);
}
return cur.toISOString().slice(0, 10);
}
export async function seed(connectionString: string): Promise<void> {
const { db, pool } = createDbClient({ connectionString });
try {
// ----- Areas -----
await db.insert(area).values(AREAS).onConflictDoNothing();
const areas = await db.select().from(area);
const areaBySlug = Object.fromEntries(areas.map((a) => [a.slug, a]));
// ----- Users -----
for (const u of USERS) {
const defaultArea = areaBySlug[u.defaultAreaSlug];
await db
.insert(user)
.values({
authentikSub: u.authentikSub,
name: u.name,
email: u.email,
isAdmin: u.isAdmin,
defaultAreaId: defaultArea?.id ?? null,
})
.onConflictDoUpdate({
target: user.authentikSub,
set: {
name: u.name,
email: u.email,
isAdmin: u.isAdmin,
defaultAreaId: defaultArea?.id ?? null,
updatedAt: new Date(),
},
});
}
const users = await db.select().from(user);
const byEmail = (m: string) => users.find((x) => x.email === m)!;
const dejan = byEmail('dejan@tool-ctrl.de');
const lukas = byEmail('lukas@tool-ctrl.de');
const anna = byEmail('anna@tool-ctrl.de');
const marco = byEmail('marco@tool-ctrl.de');
// ----- Projects -----
const existingProjects = await db.select().from(project);
if (existingProjects.length === 0) {
const ent = areaBySlug.entwicklung!;
const [proj1] = await db
.insert(project)
.values({
areaId: ent.id,
name: 'Schmidt & Söhne Auftragsportal',
kind: 'kundenprojekt',
verantwortlicheUserId: dejan.id,
arbeitsmodus: 'cycle',
outlineSteckbriefUrl: 'https://outline.tool-ctrl.de/projekte/schmidt-soehne',
steckbriefFreigegeben: true,
planeProjectUrl: 'https://plane.tool-ctrl.de/projects/schmidt',
dodText: 'Portal live, mind. 3 Auftragstypen, Stripe-Anbindung getestet.',
start: isoDateOffset(-30),
deadline: isoDateOffset(35),
statusCache: 'amber',
trendCache: 'gleich',
pflichtMinimumState: {
steckbrief_freigegeben: true,
verantwortlicher: true,
plane_board: true,
meilensteine: true,
status_aktuell: true,
retrospektive: false,
},
createdBy: dejan.id,
})
.returning();
const [proj2] = await db
.insert(project)
.values({
areaId: ent.id,
name: 'everythingbutwork Rollout intern',
kind: 'internes_projekt',
verantwortlicheUserId: dejan.id,
arbeitsmodus: 'kanban',
outlineSteckbriefUrl: 'https://outline.tool-ctrl.de/projekte/everythingbutwork-rollout',
steckbriefFreigegeben: false,
planeProjectUrl: 'https://plane.tool-ctrl.de/projects/everythingbutwork',
start: isoDateOffset(-7),
deadline: isoDateOffset(45),
statusCache: 'green',
trendCache: 'besser',
pflichtMinimumState: {
steckbrief_freigegeben: false,
verantwortlicher: true,
plane_board: true,
meilensteine: true,
status_aktuell: true,
retrospektive: false,
},
createdBy: dejan.id,
})
.returning();
const [proj3] = await db
.insert(project)
.values({
areaId: ent.id,
name: 'Kalender-Sync v2',
kind: 'internes_projekt',
verantwortlicheUserId: lukas.id,
arbeitsmodus: 'kanban',
outlineSteckbriefUrl: 'https://outline.tool-ctrl.de/projekte/kalender-v2',
steckbriefFreigegeben: true,
planeProjectUrl: 'https://plane.tool-ctrl.de/projects/kalender',
start: isoDateOffset(-14),
deadline: isoDateOffset(7),
statusCache: 'red',
trendCache: 'schlechter',
pflichtMinimumState: {
steckbrief_freigegeben: true,
verantwortlicher: true,
plane_board: true,
meilensteine: false,
status_aktuell: true,
retrospektive: false,
},
createdBy: dejan.id,
})
.returning();
if (proj1 && proj2 && proj3) {
await db.insert(milestone).values([
{ projectId: proj1.id, title: 'Auftragsformular Phase 1', due: isoDateOffset(-5), status: 'done', order: 0 },
{ projectId: proj1.id, title: 'Stripe Webhook-Tests', due: isoDateOffset(10), status: 'open', order: 1 },
{ projectId: proj1.id, title: 'Pilot mit Kunde', due: isoDateOffset(25), status: 'open', order: 2 },
{ projectId: proj2.id, title: 'Tagesrhythmus produktiv', due: isoDateOffset(5), status: 'open', order: 0 },
{ projectId: proj2.id, title: 'Pflicht-Minimum vollständig', due: isoDateOffset(20), status: 'open', order: 1 },
{ projectId: proj3.id, title: 'Token-Refresh Bugfix', due: isoDateOffset(-3), status: 'overdue', order: 0 },
]);
}
}
// ----- Deals -----
const existingDeals = await db.select().from(deal);
if (existingDeals.length === 0) {
const sales = areaBySlug.vertrieb!;
await db.insert(deal).values([
{ areaId: sales.id, name: 'Schmidt & Söhne Wartungsvertrag', ownerUserId: anna.id, valueEur: 1_200_000, probability: 70, stage: 'angebot' },
{ areaId: sales.id, name: 'Bauwerk AG Plattform', ownerUserId: anna.id, valueEur: 850_000, probability: 50, stage: 'demo' },
{ areaId: sales.id, name: 'Müller GmbH Onboarding', ownerUserId: anna.id, valueEur: 250_000, probability: 30, stage: 'erstgespraech' },
{ areaId: sales.id, name: 'Hartmann KG Pilot', ownerUserId: anna.id, valueEur: 180_000, probability: 80, stage: 'angebot' },
{
areaId: sales.id,
name: 'Becker Logistik Phase 1',
ownerUserId: anna.id,
valueEur: 950_000,
probability: 100,
stage: 'abschluss',
wonAt: new Date(),
wonValueEur: 950_000,
},
]);
}
// ----- GTM -----
const existingGtm = await db.select().from(gtmStep);
if (existingGtm.length === 0) {
await db.insert(gtmStep).values([
{ product: 'ToolCTRL Suite', title: 'Landingpage live', ownerUserId: marco.id, status: 'done', order: 0 },
{ product: 'ToolCTRL Suite', title: 'Pricing-Sheet final', ownerUserId: anna.id, status: 'in_progress', order: 1 },
{ product: 'ToolCTRL Suite', title: 'Demo-Video produzieren', ownerUserId: marco.id, status: 'open', order: 2 },
]);
}
// ----- Campaigns + LeadHandover -----
const existingCampaigns = await db.select().from(campaign);
if (existingCampaigns.length === 0) {
const mkt = areaBySlug.marketing!;
const [c1, c2] = await db
.insert(campaign)
.values([
{ areaId: mkt.id, name: 'LinkedIn Q2 Outbound', status: 'active', reach: 18500, ownerUserId: marco.id },
{ areaId: mkt.id, name: 'Webinar-Serie „Workflows"', status: 'active', reach: 4200, ownerUserId: marco.id },
{ areaId: mkt.id, name: 'Newsletter Spring', status: 'done', reach: 2200, ownerUserId: marco.id },
])
.returning();
if (c1 && c2) {
await db.insert(leadHandover).values([
{ campaignId: c1.id, uebergebenVon: marco.id },
{ campaignId: c1.id, uebergebenVon: marco.id },
{ campaignId: c1.id, uebergebenVon: marco.id },
{ campaignId: c2.id, uebergebenVon: marco.id },
]);
}
}
// ----- DayEntries + Todos -----
const existingEntries = await db.select().from(dayEntry);
if (existingEntries.length === 0) {
const yesterday = pickWerktag(1);
const twoDays = pickWerktag(2);
const [d1] = await db
.insert(dayEntry)
.values({
userId: dejan.id,
date: yesterday,
status: 'full',
checkedInAt: new Date(`${yesterday}T08:30:00+02:00`),
reflectedAt: new Date(`${yesterday}T17:30:00+02:00`),
reflectionText: 'Stripe-Webhook-Setup steht. Pilot-Demo morgen vorbereitet.',
ampel: 'green',
trend: 'besser',
publishedAt: new Date(),
})
.returning();
if (d1) {
await db.insert(todo).values([
{ dayEntryId: d1.id, userId: dejan.id, date: yesterday, title: 'Stripe-Webhook implementieren', order: 0, status: 'done', bezugKind: 'project' },
{ dayEntryId: d1.id, userId: dejan.id, date: yesterday, title: 'Pilot-Demo vorbereiten', order: 1, status: 'done', bezugKind: 'project' },
{ dayEntryId: d1.id, userId: dejan.id, date: yesterday, title: 'Marketing-Briefing review', order: 2, status: 'dropped', dropReason: 'Marco war krank' },
]);
}
const [d2] = await db
.insert(dayEntry)
.values({
userId: lukas.id,
date: yesterday,
status: 'full',
checkedInAt: new Date(`${yesterday}T09:00:00+02:00`),
reflectedAt: new Date(`${yesterday}T18:00:00+02:00`),
reflectionText: 'Token-Refresh Bug reproduziert, Fix-Hypothese steht. Komme morgen weiter.',
ampel: 'red',
trend: 'schlechter',
publishedAt: new Date(),
})
.returning();
if (d2) {
await db.insert(todo).values([
{ dayEntryId: d2.id, userId: lukas.id, date: yesterday, title: 'Token-Refresh debuggen', order: 0, status: 'open', bezugKind: 'project' },
{ dayEntryId: d2.id, userId: lukas.id, date: yesterday, title: 'PR Review für Anna', order: 1, status: 'done' },
]);
}
const [d3] = await db
.insert(dayEntry)
.values({
userId: anna.id,
date: yesterday,
status: 'full',
checkedInAt: new Date(`${yesterday}T08:15:00+02:00`),
reflectedAt: new Date(`${yesterday}T16:45:00+02:00`),
reflectionText: 'Hartmann-Angebot rausgeschickt. Schmidt-Wartung nächste Woche Demo.',
ampel: 'green',
trend: 'gleich',
publishedAt: new Date(),
})
.returning();
if (d3) {
await db.insert(todo).values([
{ dayEntryId: d3.id, userId: anna.id, date: yesterday, title: 'Hartmann-Angebot rausschicken', order: 0, status: 'done', bezugKind: 'deal' },
{ dayEntryId: d3.id, userId: anna.id, date: yesterday, title: 'Demo-Termin Schmidt fixieren', order: 1, status: 'done', bezugKind: 'deal' },
]);
}
// Marco hat sich vor 2 Tagen eingestempelt, aber nie reflektiert
await db.insert(dayEntry).values({
userId: marco.id,
date: twoDays,
status: 'begun',
checkedInAt: new Date(`${twoDays}T10:00:00+02:00`),
publishedAt: new Date(),
});
}
// ----- Blockers -----
const existingBlockers = await db.select().from(blocker);
if (existingBlockers.length === 0) {
await db.insert(blocker).values([
{
title: 'Plane-API-Token läuft regelmäßig ab',
description: 'Issue-Sync zum Plane bricht alle 24h, brauchen Refresh-Pattern.',
openedBy: lukas.id,
ownerUserId: dejan.id,
},
{
title: 'Kunde Schmidt liefert Stripe-Account-Daten nicht',
description: 'Seit 5 Tagen Nachfrage offen, Pilot kann ohne nicht starten.',
openedBy: dejan.id,
ownerUserId: anna.id,
},
]);
}
// ----- Leads (CRM-Dossiers) -----
const existingLeads = await db.select().from(lead);
if (existingLeads.length === 0) {
const sales = areaBySlug.vertrieb!;
const [l1] = await db
.insert(lead)
.values({
areaId: sales.id,
firma: 'Alpenhof Hotels GmbH',
status: 'hot',
score: 8,
ownerUserId: anna.id,
branche: 'Hotellerie',
subSegment: 'Internationale/deutsche Hotelketten',
standorte: 'München, Wien, Zürich',
mitarbeiterzahl: '350+',
umsatz: '~ 80 Mio € (2025)',
website: 'https://alpenhof-hotels.example',
quelle: 'LinkedIn-Profil im MitApp-Verzeichnis',
painPoint:
'Projektmanagement-Workflows laufen über zig Excel-Listen, nichts ist zentral. Onboarding neuer Häuser dauert ewig.',
statusQuoKosten: 'Mehrere Personentage pro Woche an manueller Koordination.',
bestehendeLoesung: 'Excel + E-Mail, vereinzelt Trello',
wechselgrund: 'Skaliert nicht mit dem Wachstum, zu fehleranfällig.',
persoenlicherErfolg: 'Will als Onboarding-Manager sichtbar Zeit einsparen und entlasten.',
budgetIndikation: 'noch offen, vermutlich 5-stellig p.a.',
entscheidungshorizont: 'Q3',
compellingEvent: 'Zwei neue Häuser kommen im Herbst dazu.',
mitbewerber: 'keine konkret bekannt',
buyingCenter: 'Onboarding-Manager (Champion), COO (Entscheider)',
beschaffungsweg: 'direkt',
kommunikationsstil: 'freundlich, pragmatisch, mag konkrete Beispiele',
hotButtons: 'Zeitersparnis, weniger manuelle Pflege, KI-Unterstützung',
nutzenHypothese:
'KI-gestützte PM-Workflows sparen dem Onboarding-Team messbar Zeit bei jedem neuen Haus.',
differenzierer: 'Eigene Erfahrung in genau dieser Nische + KI-/Prozessautomatisierung.',
naechsterSchritt: 'Discovery-Call vereinbaren',
naechsterSchrittDatum: isoDateOffset(4),
vertrauenslevel: 3,
offeneFragen: [
{ text: 'Wer hält das Budget?', done: false },
{ text: 'Welche Tools sind heute schon im Einsatz?', done: true },
],
anhaenge: [],
createdBy: anna.id,
})
.returning();
if (l1) {
await db.insert(leadContact).values({
leadId: l1.id,
name: 'Sascha Haiss',
rolle: 'Onboarding Manager',
funktion: 'champion',
linkedin: 'https://www.linkedin.com/in/example',
sprache: 'Deutsch, per Du',
isPrimary: true,
order: 0,
});
await db.insert(leadTouchpoint).values([
{
leadId: l1.id,
datum: pickWerktag(2),
kanal: 'linkedin',
bearbeiterUserId: anna.id,
inhalt: 'Erste Nachricht über das MitApp-Profil geschickt, Bezug auf KI-Optimierung für PM.',
outcome: 'positiv geantwortet, Interesse an kurzem Austausch',
followUpBis: isoDateOffset(4),
},
]);
}
}
// ----- Risiken / Chancen / Stakeholder / Modus (PM-Insights) -----
const existingRisks = await db.select().from(risk);
if (existingRisks.length === 0) {
const someProject = (await db.select().from(project).orderBy(project.createdAt).limit(1))[0];
if (someProject) {
await db
.update(project)
.set({
modus: 'eskalation',
maxime: someProject.maxime ?? 'Pilot ohne Scope-Creep bis zum Stichtag liefern.',
})
.where(eq(project.id, someProject.id));
await db.insert(risk).values([
{
projectId: someProject.id,
kind: 'risiko',
title: 'Kunde liefert die Stripe-Account-Daten nicht rechtzeitig.',
wahrscheinlichkeit: 'hoch',
auswirkung: 'hoch',
herkunft: 'extern',
showstopper: true,
massnahme: 'Eskalation an Kundenkontakt, Deadline schriftlich fixieren.',
status: 'offen',
createdBy: dejan.id,
},
{
projectId: someProject.id,
kind: 'risiko',
title: 'Interne Ressourcen werden für ein anderes Projekt abgezogen.',
wahrscheinlichkeit: 'mittel',
auswirkung: 'hoch',
herkunft: 'intern',
massnahme: 'Kapazität mit der Geschäftsführung absichern.',
status: 'beobachtet',
createdBy: dejan.id,
},
{
projectId: someProject.id,
kind: 'chance',
title: 'Kunde will bei Erfolg weitere Standorte anbinden.',
wahrscheinlichkeit: 'mittel',
auswirkung: 'hoch',
massnahme: 'Pilot als Referenz sauber dokumentieren.',
status: 'offen',
createdBy: dejan.id,
},
]);
await db.insert(stakeholder).values([
{
projectId: someProject.id,
name: 'GF Schmidt',
rolle: 'Auftraggeber',
interesse: 'hoch',
einfluss: 'hoch',
haltung: 'pro',
beduerfnis: 'Pünktlicher Go-Live, klare Status-Reports',
order: 0,
},
{
projectId: someProject.id,
name: 'IT-Abteilung Kunde',
rolle: 'Technik',
interesse: 'mittel',
einfluss: 'hoch',
haltung: 'neutral',
beduerfnis: 'Saubere Schnittstellen, wenig Mehraufwand',
order: 1,
},
{
projectId: someProject.id,
name: 'Sachbearbeitung',
rolle: 'End-User',
interesse: 'hoch',
einfluss: 'niedrig',
haltung: 'pro',
beduerfnis: 'Einfaches, schnelles Auftragsformular',
order: 2,
},
]);
}
}
// ----- Statusbericht / Phase / Definition / Änderung (Leitfaden) -----
const existingStatus = await db.select().from(projectStatus);
if (existingStatus.length === 0) {
const someProject = (await db.select().from(project).orderBy(project.createdAt).limit(1))[0];
if (someProject) {
await db
.update(project)
.set({
phase: 'umsetzung',
ziel:
someProject.ziel ??
'Auftragsportal live bis 30.09., mind. 3 Auftragstypen, vom Kunden abgenommen.',
nichtZiele:
someProject.nichtZiele ??
'Keine Migration der Altbestellungen. Keine mobile App in dieser Phase.',
liefergegenstaende:
someProject.liefergegenstaende ??
'Portal-Frontend, Stripe-Anbindung, Admin-Übersicht, Schulungsunterlagen.',
zieldreieckFlex: someProject.zieldreieckFlex ?? 'aufwand',
statusCache: 'amber',
trendCache: 'gleich',
})
.where(eq(project.id, someProject.id));
await db.insert(projectStatus).values([
{
projectId: someProject.id,
ampel: 'green',
trend: 'besser',
text: 'Setup steht, erste Auftragstypen umgesetzt. Im Plan.',
externSichtbar: true,
createdBy: dejan.id,
},
{
projectId: someProject.id,
ampel: 'amber',
trend: 'gleich',
text: 'Stripe-Zugang vom Kunden noch offen — gefährdet den Pilottermin. Maßnahme läuft.',
externSichtbar: true,
createdBy: dejan.id,
},
]);
await db.insert(changeEntry).values({
projectId: someProject.id,
titel: 'Kunde wünscht zusätzlichen Auftragstyp „Express".',
auswirkung: 'Mehr Umfang → Aufwand steigt, Termin gefährdet.',
entscheidung: 'offen',
createdBy: dejan.id,
});
}
}
await db.update(user).set({ updatedAt: new Date() }).where(sql`true`);
} finally {
await pool.end();
}
}
const isMain = import.meta.url === `file://${process.argv[1]}`;
if (isMain) {
const url = process.env.DATABASE_URL ?? 'postgres://cockpit:cockpit_dev@localhost:55432/cockpit';
seed(url).then(
() => {
console.log('Seed complete.');
process.exit(0);
},
(err) => {
console.error(err);
process.exit(1);
},
);
}