// 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>().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>() .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;