Internes Project-Management-Tool für ToolCTRL — ergänzend zu Plane (Tickets) und Outline (Doku). Umfasst Puls-Übersicht, Tagesrhythmus (Lock-in + Reflexion), Bereichs-Detail-Views (Entwicklung/Vertrieb/Marketing), Pflicht-Minimum-Check und Blocker-Lifecycle gemäß Projektmanagement-Leitfaden v3.0. Stack: pnpm-Monorepo · Vite + React 18 + Tailwind · Fastify + tRPC v11 + Drizzle ORM · Postgres 16 · Authentik OIDC (Phase 2, Dev-Auto-Login aktiv). Planungs-Artefakte unter _bmad-output/planning-artifacts/. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
389 lines
14 KiB
TypeScript
389 lines
14 KiB
TypeScript
// Seed-Skript: idempotent. Legt Areas, Test-Mitarbeiter (inkl. Admin Dejan) und
|
|
// Dummy-Daten (Projekte, Deals, Kampagnen, Tageseinträge, Blocker) an.
|
|
|
|
import { sql } from 'drizzle-orm';
|
|
import { createDbClient } from './client.js';
|
|
import {
|
|
area,
|
|
user,
|
|
project,
|
|
milestone,
|
|
deal,
|
|
campaign,
|
|
leadHandover,
|
|
gtmStep,
|
|
dayEntry,
|
|
todo,
|
|
blocker,
|
|
} 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,
|
|
},
|
|
]);
|
|
}
|
|
|
|
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);
|
|
},
|
|
);
|
|
}
|