// 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 { 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); }, ); }