Initial commit: everythingbutwork MVP
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>
This commit is contained in:
commit
4f33509a0f
131 files changed
+34339
No files matched your search
@@ -0,0 +1,23 @@
|
||||
import { drizzle, type NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import pg from 'pg';
|
||||
import * as schema from './schema.js';
|
||||
|
||||
export type Db = NodePgDatabase<typeof schema>;
|
||||
|
||||
export interface CreateDbClientOptions {
|
||||
connectionString: string;
|
||||
poolSize?: number;
|
||||
}
|
||||
|
||||
export function createDbClient(options: CreateDbClientOptions): {
|
||||
db: Db;
|
||||
pool: pg.Pool;
|
||||
} {
|
||||
const pool = new pg.Pool({
|
||||
connectionString: options.connectionString,
|
||||
max: options.poolSize ?? 10,
|
||||
});
|
||||
|
||||
const db = drizzle(pool, { schema });
|
||||
return { db, pool };
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './schema.js';
|
||||
export { createDbClient } from './client.js';
|
||||
export type { Db, CreateDbClientOptions } from './client.js';
|
||||
|
||||
export const DB_VERSION = '0.0.1';
|
||||
@@ -0,0 +1,440 @@
|
||||
// Drizzle-Schema — Single Source of Truth.
|
||||
// Wird inkrementell pro Story erweitert. In Story 1.2 nur Fundament:
|
||||
// user, session, audit_event.
|
||||
|
||||
import { sql } from 'drizzle-orm';
|
||||
import {
|
||||
pgTable,
|
||||
text,
|
||||
uuid,
|
||||
boolean,
|
||||
timestamp,
|
||||
date,
|
||||
integer,
|
||||
jsonb,
|
||||
index,
|
||||
uniqueIndex,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// area — fachlicher Bereich (Entwicklung, Vertrieb, Marketing, Allgemein).
|
||||
// ----------------------------------------------------------------------------
|
||||
export const area = pgTable(
|
||||
'area',
|
||||
{
|
||||
id: uuid('id')
|
||||
.default(sql`gen_random_uuid()`)
|
||||
.primaryKey(),
|
||||
slug: text('slug').notNull(),
|
||||
name: text('name').notNull(),
|
||||
kind: text('kind', { enum: ['dev', 'sales', 'marketing', 'allgemein'] }).notNull(),
|
||||
order: integer('order').notNull().default(0),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
uniqSlug: uniqueIndex('uniq_area_slug').on(t.slug),
|
||||
}),
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// user — Mitarbeiter. Identität kommt aus Authentik (sub) oder ist lokal in
|
||||
// Dev/Test angelegt.
|
||||
// ----------------------------------------------------------------------------
|
||||
export const user = pgTable(
|
||||
'user',
|
||||
{
|
||||
id: uuid('id')
|
||||
.default(sql`gen_random_uuid()`)
|
||||
.primaryKey(),
|
||||
authentikSub: text('authentik_sub').notNull(),
|
||||
name: text('name').notNull(),
|
||||
email: text('email').notNull(),
|
||||
defaultAreaId: uuid('default_area_id'),
|
||||
isAdmin: boolean('is_admin').notNull().default(false),
|
||||
active: boolean('active').notNull().default(true),
|
||||
abwesendBis: timestamp('abwesend_bis', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
uniqAuthentikSub: uniqueIndex('uniq_user_authentik_sub').on(t.authentikSub),
|
||||
uniqEmail: uniqueIndex('uniq_user_email').on(t.email),
|
||||
}),
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// session — Server-Side Session. Browser bekommt nur `id` als HttpOnly-Cookie.
|
||||
// ----------------------------------------------------------------------------
|
||||
export const session = pgTable(
|
||||
'session',
|
||||
{
|
||||
id: uuid('id')
|
||||
.default(sql`gen_random_uuid()`)
|
||||
.primaryKey(),
|
||||
userId: uuid('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
ipAddress: text('ip_address'),
|
||||
userAgent: text('user_agent'),
|
||||
},
|
||||
(t) => ({
|
||||
idxUserId: index('idx_session_user_id').on(t.userId),
|
||||
idxExpiresAt: index('idx_session_expires_at').on(t.expiresAt),
|
||||
}),
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// audit_event — Audit-Trail für jede mutierende Operation. Gefüllt von der
|
||||
// `withAudit`-Middleware (Story 1.4).
|
||||
// ----------------------------------------------------------------------------
|
||||
export const auditEvent = pgTable(
|
||||
'audit_event',
|
||||
{
|
||||
id: uuid('id')
|
||||
.default(sql`gen_random_uuid()`)
|
||||
.primaryKey(),
|
||||
actorUserId: uuid('actor_user_id').references(() => user.id, {
|
||||
onDelete: 'set null',
|
||||
}),
|
||||
action: text('action').notNull(), // e.g. 'day_entry.reflection_submitted'
|
||||
entityKind: text('entity_kind').notNull(), // e.g. 'day_entry'
|
||||
entityId: uuid('entity_id'),
|
||||
payload: jsonb('payload').$type<Record<string, unknown>>().notNull().default({}),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
idxActor: index('idx_audit_event_actor').on(t.actorUserId),
|
||||
idxAction: index('idx_audit_event_action').on(t.action),
|
||||
idxEntity: index('idx_audit_event_entity').on(t.entityKind, t.entityId),
|
||||
}),
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// project — Projekt im Sichtmodell. Wahrheit lebt im Outline-Steckbrief.
|
||||
// ----------------------------------------------------------------------------
|
||||
export const project = pgTable(
|
||||
'project',
|
||||
{
|
||||
id: uuid('id')
|
||||
.default(sql`gen_random_uuid()`)
|
||||
.primaryKey(),
|
||||
areaId: uuid('area_id')
|
||||
.notNull()
|
||||
.references(() => area.id, { onDelete: 'restrict' }),
|
||||
name: text('name').notNull(),
|
||||
kind: text('kind', { enum: ['kundenprojekt', 'internes_projekt'] }).notNull(),
|
||||
verantwortlicheUserId: uuid('verantwortliche_user_id').references(() => user.id, {
|
||||
onDelete: 'set null',
|
||||
}),
|
||||
arbeitsmodus: text('arbeitsmodus', { enum: ['cycle', 'kanban'] }),
|
||||
outlineSteckbriefUrl: text('outline_steckbrief_url'),
|
||||
steckbriefFreigegeben: boolean('steckbrief_freigegeben').notNull().default(false),
|
||||
planeProjectUrl: text('plane_project_url'),
|
||||
risikolisteUrl: text('risikoliste_url'),
|
||||
dodText: text('dod_text'),
|
||||
start: date('start'),
|
||||
deadline: date('deadline'),
|
||||
statusCache: text('status_cache', { enum: ['green', 'amber', 'red'] }),
|
||||
trendCache: text('trend_cache', { enum: ['besser', 'gleich', 'schlechter'] }),
|
||||
pflichtMinimumState: jsonb('pflicht_minimum_state')
|
||||
.$type<Record<string, boolean>>()
|
||||
.notNull()
|
||||
.default({}),
|
||||
archiviert: boolean('archiviert').notNull().default(false),
|
||||
archiviertAm: timestamp('archiviert_am', { withTimezone: true }),
|
||||
lessonsLearnedUrl: text('lessons_learned_url'),
|
||||
createdBy: uuid('created_by').references(() => user.id, { onDelete: 'set null' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
idxArea: index('idx_project_area').on(t.areaId),
|
||||
idxArchived: index('idx_project_archived').on(t.archiviert),
|
||||
}),
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// milestone — Sicht-Meilenstein pro Projekt.
|
||||
// ----------------------------------------------------------------------------
|
||||
export const milestone = pgTable(
|
||||
'milestone',
|
||||
{
|
||||
id: uuid('id')
|
||||
.default(sql`gen_random_uuid()`)
|
||||
.primaryKey(),
|
||||
projectId: uuid('project_id')
|
||||
.notNull()
|
||||
.references(() => project.id, { onDelete: 'cascade' }),
|
||||
title: text('title').notNull(),
|
||||
due: date('due'),
|
||||
status: text('status', { enum: ['open', 'done', 'overdue'] })
|
||||
.notNull()
|
||||
.default('open'),
|
||||
description: text('description'),
|
||||
order: integer('order').notNull().default(0),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
idxProject: index('idx_milestone_project').on(t.projectId),
|
||||
}),
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// day_entry — der zweistufige Tageseintrag (Morgen-Einstempeln + Abend-Reflexion).
|
||||
// Sichtbarkeit: tagsüber privat; ab `published_at` für Kollegen sichtbar.
|
||||
// ----------------------------------------------------------------------------
|
||||
export const dayEntry = pgTable(
|
||||
'day_entry',
|
||||
{
|
||||
id: uuid('id')
|
||||
.default(sql`gen_random_uuid()`)
|
||||
.primaryKey(),
|
||||
userId: uuid('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
date: date('date').notNull(), // YYYY-MM-DD im Europe/Berlin
|
||||
status: text('status', { enum: ['begun', 'full', 'absent'] }).notNull(),
|
||||
checkedInAt: timestamp('checked_in_at', { withTimezone: true }),
|
||||
reflectedAt: timestamp('reflected_at', { withTimezone: true }),
|
||||
reflectionText: text('reflection_text'),
|
||||
ampel: text('ampel', { enum: ['green', 'amber', 'red'] }),
|
||||
trend: text('trend', { enum: ['besser', 'gleich', 'schlechter'] }),
|
||||
url: text('url'),
|
||||
blockerId: uuid('blocker_id'),
|
||||
editLockedAt: timestamp('edit_locked_at', { withTimezone: true }),
|
||||
publishedAt: timestamp('published_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
uniqUserDate: uniqueIndex('uniq_day_entry_user_date').on(t.userId, t.date),
|
||||
idxDate: index('idx_day_entry_date').on(t.date),
|
||||
idxPublishedAt: index('idx_day_entry_published_at').on(t.publishedAt),
|
||||
}),
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// todo — Tagesplan-Item. Gehört zu einem day_entry.
|
||||
// ----------------------------------------------------------------------------
|
||||
export const todo = pgTable(
|
||||
'todo',
|
||||
{
|
||||
id: uuid('id')
|
||||
.default(sql`gen_random_uuid()`)
|
||||
.primaryKey(),
|
||||
dayEntryId: uuid('day_entry_id')
|
||||
.notNull()
|
||||
.references(() => dayEntry.id, { onDelete: 'cascade' }),
|
||||
userId: uuid('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
date: date('date').notNull(),
|
||||
title: text('title').notNull(),
|
||||
order: integer('order').notNull().default(0),
|
||||
bezugKind: text('bezug_kind', { enum: ['project', 'deal', 'campaign', 'allgemein'] }),
|
||||
bezugId: uuid('bezug_id'),
|
||||
status: text('status', { enum: ['open', 'done', 'dropped'] })
|
||||
.notNull()
|
||||
.default('open'),
|
||||
dropReason: text('drop_reason'),
|
||||
carriedOver: boolean('carried_over').notNull().default(false),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
completedAt: timestamp('completed_at', { withTimezone: true }),
|
||||
droppedAt: timestamp('dropped_at', { withTimezone: true }),
|
||||
},
|
||||
(t) => ({
|
||||
idxDayEntry: index('idx_todo_day_entry_id').on(t.dayEntryId),
|
||||
idxUserDate: index('idx_todo_user_date').on(t.userId, t.date),
|
||||
idxUserCarry: index('idx_todo_user_status_carry').on(t.userId, t.status, t.carriedOver),
|
||||
}),
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// absence_day — Markiert einen Werktag als „abwesend" (Urlaub/Krank).
|
||||
// Zählt nicht gegen die Streak.
|
||||
// ----------------------------------------------------------------------------
|
||||
export const absenceDay = pgTable(
|
||||
'absence_day',
|
||||
{
|
||||
id: uuid('id')
|
||||
.default(sql`gen_random_uuid()`)
|
||||
.primaryKey(),
|
||||
userId: uuid('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
date: date('date').notNull(),
|
||||
reason: text('reason', { enum: ['urlaub', 'krank', 'sonstiges'] }).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
uniqUserDate: uniqueIndex('uniq_absence_user_date').on(t.userId, t.date),
|
||||
}),
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// deal — Vertriebs-Pipeline-Eintrag.
|
||||
// ----------------------------------------------------------------------------
|
||||
export const deal = pgTable(
|
||||
'deal',
|
||||
{
|
||||
id: uuid('id')
|
||||
.default(sql`gen_random_uuid()`)
|
||||
.primaryKey(),
|
||||
areaId: uuid('area_id')
|
||||
.notNull()
|
||||
.references(() => area.id, { onDelete: 'restrict' }),
|
||||
name: text('name').notNull(),
|
||||
ownerUserId: uuid('owner_user_id').references(() => user.id, { onDelete: 'set null' }),
|
||||
valueEur: integer('value_eur').notNull().default(0), // Cent
|
||||
probability: integer('probability').notNull().default(50), // 0-100
|
||||
stage: text('stage', {
|
||||
enum: ['lead', 'erstgespraech', 'demo', 'angebot', 'abschluss'],
|
||||
}).notNull(),
|
||||
enteredStageAt: timestamp('entered_stage_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
wonAt: timestamp('won_at', { withTimezone: true }),
|
||||
wonValueEur: integer('won_value_eur'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
idxStage: index('idx_deal_stage').on(t.stage),
|
||||
idxOwner: index('idx_deal_owner').on(t.ownerUserId),
|
||||
}),
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// gtm_step — Go-to-Market-Aufgabe pro Produkt.
|
||||
// ----------------------------------------------------------------------------
|
||||
export const gtmStep = pgTable(
|
||||
'gtm_step',
|
||||
{
|
||||
id: uuid('id')
|
||||
.default(sql`gen_random_uuid()`)
|
||||
.primaryKey(),
|
||||
product: text('product').notNull(),
|
||||
title: text('title').notNull(),
|
||||
ownerUserId: uuid('owner_user_id').references(() => user.id, { onDelete: 'set null' }),
|
||||
status: text('status', { enum: ['open', 'in_progress', 'done'] })
|
||||
.notNull()
|
||||
.default('open'),
|
||||
order: integer('order').notNull().default(0),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
idxProduct: index('idx_gtm_product').on(t.product),
|
||||
}),
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// campaign — Marketing-Kampagne.
|
||||
// ----------------------------------------------------------------------------
|
||||
export const campaign = pgTable(
|
||||
'campaign',
|
||||
{
|
||||
id: uuid('id')
|
||||
.default(sql`gen_random_uuid()`)
|
||||
.primaryKey(),
|
||||
areaId: uuid('area_id')
|
||||
.notNull()
|
||||
.references(() => area.id, { onDelete: 'restrict' }),
|
||||
name: text('name').notNull(),
|
||||
status: text('status', { enum: ['active', 'paused', 'done'] })
|
||||
.notNull()
|
||||
.default('active'),
|
||||
reach: integer('reach').notNull().default(0),
|
||||
ownerUserId: uuid('owner_user_id').references(() => user.id, { onDelete: 'set null' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
idxStatus: index('idx_campaign_status').on(t.status),
|
||||
}),
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// lead_handover — Marketing → Sales Übergabe (Crosslink-Banner).
|
||||
// ----------------------------------------------------------------------------
|
||||
export const leadHandover = pgTable('lead_handover', {
|
||||
id: uuid('id')
|
||||
.default(sql`gen_random_uuid()`)
|
||||
.primaryKey(),
|
||||
campaignId: uuid('campaign_id').references(() => campaign.id, { onDelete: 'set null' }),
|
||||
dealId: uuid('deal_id').references(() => deal.id, { onDelete: 'set null' }),
|
||||
uebergebenAm: timestamp('uebergeben_am', { withTimezone: true }).notNull().defaultNow(),
|
||||
uebergebenVon: uuid('uebergeben_von').references(() => user.id, { onDelete: 'set null' }),
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// blocker — bereichsübergreifendes Hindernis. Wird inline in der Reflexion
|
||||
// oder über „Neuer Blocker"-Button (Story 5.3) erfasst.
|
||||
// ----------------------------------------------------------------------------
|
||||
export const blocker = pgTable(
|
||||
'blocker',
|
||||
{
|
||||
id: uuid('id')
|
||||
.default(sql`gen_random_uuid()`)
|
||||
.primaryKey(),
|
||||
title: text('title').notNull(),
|
||||
description: text('description'),
|
||||
areaId: uuid('area_id'),
|
||||
projectId: uuid('project_id'),
|
||||
ownerUserId: uuid('owner_user_id').references(() => user.id, { onDelete: 'set null' }),
|
||||
openedBy: uuid('opened_by')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
openedAt: timestamp('opened_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
resolvedBy: uuid('resolved_by').references(() => user.id, { onDelete: 'set null' }),
|
||||
resolvedAt: timestamp('resolved_at', { withTimezone: true }),
|
||||
resolutionText: text('resolution_text'),
|
||||
},
|
||||
(t) => ({
|
||||
idxArea: index('idx_blocker_area').on(t.areaId),
|
||||
idxProject: index('idx_blocker_project').on(t.projectId),
|
||||
idxOpenedAt: index('idx_blocker_opened_at').on(t.openedAt),
|
||||
}),
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Type-Helper für Code, der mit den Entitäten arbeitet.
|
||||
// ----------------------------------------------------------------------------
|
||||
export type User = typeof user.$inferSelect;
|
||||
export type NewUser = typeof user.$inferInsert;
|
||||
|
||||
export type Session = typeof session.$inferSelect;
|
||||
export type NewSession = typeof session.$inferInsert;
|
||||
|
||||
export type AuditEvent = typeof auditEvent.$inferSelect;
|
||||
export type NewAuditEvent = typeof auditEvent.$inferInsert;
|
||||
|
||||
export type DayEntry = typeof dayEntry.$inferSelect;
|
||||
export type NewDayEntry = typeof dayEntry.$inferInsert;
|
||||
|
||||
export type Todo = typeof todo.$inferSelect;
|
||||
export type NewTodo = typeof todo.$inferInsert;
|
||||
|
||||
export type AbsenceDay = typeof absenceDay.$inferSelect;
|
||||
export type NewAbsenceDay = typeof absenceDay.$inferInsert;
|
||||
|
||||
export type Blocker = typeof blocker.$inferSelect;
|
||||
export type NewBlocker = typeof blocker.$inferInsert;
|
||||
|
||||
export type Area = typeof area.$inferSelect;
|
||||
export type NewArea = typeof area.$inferInsert;
|
||||
|
||||
export type Project = typeof project.$inferSelect;
|
||||
export type NewProject = typeof project.$inferInsert;
|
||||
|
||||
export type Milestone = typeof milestone.$inferSelect;
|
||||
export type NewMilestone = typeof milestone.$inferInsert;
|
||||
|
||||
export type Deal = typeof deal.$inferSelect;
|
||||
export type NewDeal = typeof deal.$inferInsert;
|
||||
|
||||
export type GtmStep = typeof gtmStep.$inferSelect;
|
||||
export type NewGtmStep = typeof gtmStep.$inferInsert;
|
||||
|
||||
export type Campaign = typeof campaign.$inferSelect;
|
||||
export type NewCampaign = typeof campaign.$inferInsert;
|
||||
|
||||
export type LeadHandover = typeof leadHandover.$inferSelect;
|
||||
export type NewLeadHandover = typeof leadHandover.$inferInsert;
|
||||
@@ -0,0 +1,388 @@
|
||||
// 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);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user