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>
441 lines
17 KiB
TypeScript
441 lines
17 KiB
TypeScript
// 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;
|