// 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(), description: text('description'), // Maxime / roter Faden (ein Satz) — Leitsatz fürs ganze Team (Buch Kap. 4 Strategie). maxime: text('maxime'), // Definition (Leitfaden Phase 1) — Kurzfassung; Volltext-Steckbrief lebt in Outline. ziel: text('ziel'), // SMART formuliert nichtZiele: text('nicht_ziele'), // Scope-Schutz liefergegenstaende: text('liefergegenstaende'), // Zieldreieck (Leitfaden Kap. 1): welche Ecke gibt zuerst nach, wenn es eng wird. zieldreieckFlex: text('zieldreieck_flex', { enum: ['leistung', 'zeit', 'aufwand'] }), 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'] }), // Projekt-Modus / Intensität — muss laut Buch jederzeit ans Team kommuniziert sein. modus: text('modus', { enum: ['relax', 'standard', 'eskalation', 'taskforce'] }) .notNull() .default('standard'), // Projektlebenszyklus (Leitfaden Kap. 2): aktuelle Phase. phase: text('phase', { enum: ['definition', 'planung', 'umsetzung', 'abschluss'] }) .notNull() .default('definition'), 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'), // Abschluss (Leitfaden Phase 4): kurzer Abschlussvermerk — Ziel erreicht? Aufwand vs. Plan? abschlussvermerk: text('abschlussvermerk'), 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'), // Ergebnis-Übersicht („Patchnotes") + konkreter Kundennutzen je Meilenstein (Buch Kap. 4). ergebnis: text('ergebnis'), kundennutzen: text('kundennutzen'), 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'), // Ungeplant dazugekommen & erledigt (Drive-by, „btw das habe ich heute auch gemacht"). unplanned: boolean('unplanned').notNull().default(false), 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' }), }); // ---------------------------------------------------------------------------- // time_entry — Zeiterfassung. Pro User darf nur ein Eintrag mit ended_at IS NULL // gleichzeitig existieren (= aktiver Timer). Optional an ToDo oder Bezug gebunden. // ---------------------------------------------------------------------------- export const timeEntry = pgTable( 'time_entry', { id: uuid('id').default(sql`gen_random_uuid()`).primaryKey(), userId: uuid('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), dayEntryId: uuid('day_entry_id').references(() => dayEntry.id, { onDelete: 'set null' }), todoId: uuid('todo_id').references(() => todo.id, { onDelete: 'set null' }), bezugKind: text('bezug_kind', { enum: ['project', 'deal', 'campaign', 'allgemein'] }), bezugId: uuid('bezug_id'), category: text('category', { enum: ['focus', 'meeting', 'break', 'admin', 'learning', 'other'], }) .notNull() .default('focus'), description: text('description'), startedAt: timestamp('started_at', { withTimezone: true }).notNull(), endedAt: timestamp('ended_at', { withTimezone: true }), durationSeconds: integer('duration_seconds'), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }, (t) => ({ idxUserStart: index('idx_time_entry_user_start').on(t.userId, t.startedAt), idxUserActive: uniqueIndex('uniq_time_entry_user_active') .on(t.userId) .where(sql`${t.endedAt} IS NULL`), idxTodo: index('idx_time_entry_todo').on(t.todoId), }), ); // ---------------------------------------------------------------------------- // 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), }), ); // ---------------------------------------------------------------------------- // lead — qualitatives Vertriebs-Dossier (CRM). Reicher als `deal`: Stammdaten, // Pain, Geschäftliches, Soft Factors, Strategie. Ein Lead kann optional zu einem // Pipeline-`deal` promoted werden (promoted_deal_id). Spiegelt die interne // Lead-Schablone (9 Abschnitte). // ---------------------------------------------------------------------------- export const lead = pgTable( 'lead', { id: uuid('id') .default(sql`gen_random_uuid()`) .primaryKey(), areaId: uuid('area_id') .notNull() .references(() => area.id, { onDelete: 'restrict' }), // Optionale Verknüpfung zum (universellen) Projekt — egal in welchem Bereich // dieses wohnt. So sind „HiveGrid-Leads" mit dem HiveGrid-Projekt verbunden, // ohne dass das Projekt im Vertrieb redundant angelegt werden muss. projectId: uuid('project_id').references(() => project.id, { onDelete: 'set null' }), // 1. Stammdaten firma: text('firma').notNull(), status: text('status', { enum: ['cold', 'warm', 'hot', 'won', 'lost'] }) .notNull() .default('cold'), score: integer('score').notNull().default(0), // 0-10 ownerUserId: uuid('owner_user_id').references(() => user.id, { onDelete: 'set null' }), branche: text('branche'), subSegment: text('sub_segment'), // Nische standorte: text('standorte'), mitarbeiterzahl: text('mitarbeiterzahl'), umsatz: text('umsatz'), website: text('website'), quelle: text('quelle'), // wie auf uns aufmerksam / wie gefunden // 3. Pain & Bedarf painPoint: text('pain_point'), statusQuoKosten: text('status_quo_kosten'), bestehendeLoesung: text('bestehende_loesung'), wechselgrund: text('wechselgrund'), persoenlicherErfolg: text('persoenlicher_erfolg'), // 4. Geschäftliches budgetIndikation: text('budget_indikation'), entscheidungshorizont: text('entscheidungshorizont'), compellingEvent: text('compelling_event'), mitbewerber: text('mitbewerber'), buyingCenter: text('buying_center'), beschaffungsweg: text('beschaffungsweg'), // 5. Soft Factors kommunikationsstil: text('kommunikationsstil'), vorlieben: text('vorlieben'), hotButtons: text('hot_buttons'), noGos: text('no_gos'), druckpunkte: text('druckpunkte'), anker: text('anker'), vertrauenslevel: integer('vertrauenslevel'), // 1-5, nullable // 6. Strategie nutzenHypothese: text('nutzen_hypothese'), differenzierer: text('differenzierer'), naechsterSchritt: text('naechster_schritt'), naechsterSchrittDatum: date('naechster_schritt_datum'), risiken: text('risiken'), planB: text('plan_b'), // 8. Offene Fragen / 9. Anhänge (leichtgewichtige Listen als JSONB) offeneFragen: jsonb('offene_fragen') .$type>() .notNull() .default([]), anhaenge: jsonb('anhaenge') .$type>() .notNull() .default([]), // Promotion → Pipeline-Deal promotedDealId: uuid('promoted_deal_id').references(() => deal.id, { onDelete: 'set null' }), 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) => ({ idxStatus: index('idx_lead_status').on(t.status), idxOwner: index('idx_lead_owner').on(t.ownerUserId), idxProject: index('idx_lead_project').on(t.projectId), }), ); // ---------------------------------------------------------------------------- // lead_contact — Kontaktperson(en) eines Leads (Schablone Abschnitt 2). // ---------------------------------------------------------------------------- export const leadContact = pgTable( 'lead_contact', { id: uuid('id') .default(sql`gen_random_uuid()`) .primaryKey(), leadId: uuid('lead_id') .notNull() .references(() => lead.id, { onDelete: 'cascade' }), name: text('name').notNull(), rolle: text('rolle'), // Rolle / Titel funktion: text('funktion', { enum: ['entscheider', 'influencer', 'gatekeeper', 'end_user', 'champion'], }), email: text('email'), telefon: text('telefon'), linkedin: text('linkedin'), imBuyingCenterSeit: text('im_buying_center_seit'), sprache: text('sprache'), // Sprache / bevorzugte Anrede isPrimary: boolean('is_primary').notNull().default(false), order: integer('order').notNull().default(0), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }, (t) => ({ idxLead: index('idx_lead_contact_lead').on(t.leadId), }), ); // ---------------------------------------------------------------------------- // lead_touchpoint — Austausch-Log (Schablone Abschnitt 7). Jeder Kontakt eine Zeile. // ---------------------------------------------------------------------------- export const leadTouchpoint = pgTable( 'lead_touchpoint', { id: uuid('id') .default(sql`gen_random_uuid()`) .primaryKey(), leadId: uuid('lead_id') .notNull() .references(() => lead.id, { onDelete: 'cascade' }), datum: date('datum').notNull(), kanal: text('kanal', { enum: ['call', 'mail', 'linkedin', 'termin', 'event', 'sonstiges'], }).notNull(), bearbeiterUserId: uuid('bearbeiter_user_id').references(() => user.id, { onDelete: 'set null' }), inhalt: text('inhalt').notNull(), outcome: text('outcome'), followUpBis: date('follow_up_bis'), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }, (t) => ({ idxLead: index('idx_lead_touchpoint_lead').on(t.leadId), idxFollowUp: index('idx_lead_touchpoint_followup').on(t.followUpBis), }), ); // ---------------------------------------------------------------------------- // stakeholder — Stakeholder-Analyse pro Projekt (Buch Kap. 4). Interesse × // Einfluss ergibt die Macht/Interesse-Matrix; Haltung = Grundeinstellung. // ---------------------------------------------------------------------------- export const stakeholder = pgTable( 'stakeholder', { id: uuid('id') .default(sql`gen_random_uuid()`) .primaryKey(), projectId: uuid('project_id') .notNull() .references(() => project.id, { onDelete: 'cascade' }), name: text('name').notNull(), rolle: text('rolle'), interesse: text('interesse', { enum: ['niedrig', 'mittel', 'hoch'] }) .notNull() .default('mittel'), einfluss: text('einfluss', { enum: ['niedrig', 'mittel', 'hoch'] }) .notNull() .default('mittel'), haltung: text('haltung', { enum: ['pro', 'neutral', 'contra'] }) .notNull() .default('neutral'), beduerfnis: text('beduerfnis'), // Was braucht die Person, in welcher Priorität? notiz: text('notiz'), order: integer('order').notNull().default(0), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }, (t) => ({ idxProject: index('idx_stakeholder_project').on(t.projectId), }), ); // ---------------------------------------------------------------------------- // risk — Risiko- & Chancen-Register pro Projekt (Buch Kap. 4). `kind` // unterscheidet Gefahr vs. Chance. Showstopper = bringt das Projekt zum Stillstand. // ---------------------------------------------------------------------------- export const risk = pgTable( 'risk', { id: uuid('id') .default(sql`gen_random_uuid()`) .primaryKey(), projectId: uuid('project_id') .notNull() .references(() => project.id, { onDelete: 'cascade' }), kind: text('kind', { enum: ['risiko', 'chance'] }) .notNull() .default('risiko'), title: text('title').notNull(), // ausformulierter Satz wahrscheinlichkeit: text('wahrscheinlichkeit', { enum: ['niedrig', 'mittel', 'hoch'] }) .notNull() .default('mittel'), auswirkung: text('auswirkung', { enum: ['niedrig', 'mittel', 'hoch'] }) .notNull() .default('mittel'), herkunft: text('herkunft', { enum: ['intern', 'extern'] }), showstopper: boolean('showstopper').notNull().default(false), massnahme: text('massnahme'), // Gegenmaßnahme bzw. Nutzungsidee bei Chancen // Strategie (Leitfaden Kap. 4): vermeiden / verlagern / mindern / akzeptieren. strategie: text('strategie', { enum: ['vermeiden', 'verlagern', 'mindern', 'akzeptieren'] }), ownerUserId: uuid('owner_user_id').references(() => user.id, { onDelete: 'set null' }), status: text('status', { enum: ['offen', 'beobachtet', 'eingetreten', 'geschlossen'] }) .notNull() .default('offen'), 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) => ({ idxProject: index('idx_risk_project').on(t.projectId), idxShowstopper: index('idx_risk_showstopper').on(t.showstopper), }), ); // ---------------------------------------------------------------------------- // project_status — Statusbericht pro Projekt (Leitfaden Kap. 5 „Status sichtbar // machen"): Ampel + Trend + 2-3 Sätze. Jüngster Eintrag speist statusCache. // ---------------------------------------------------------------------------- export const projectStatus = pgTable( 'project_status', { id: uuid('id') .default(sql`gen_random_uuid()`) .primaryKey(), projectId: uuid('project_id') .notNull() .references(() => project.id, { onDelete: 'cascade' }), ampel: text('ampel', { enum: ['green', 'amber', 'red'] }).notNull(), trend: text('trend', { enum: ['besser', 'gleich', 'schlechter'] }), text: text('text'), // 2-3 Sätze Erläuterung externSichtbar: boolean('extern_sichtbar').notNull().default(false), createdBy: uuid('created_by').references(() => user.id, { onDelete: 'set null' }), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }, (t) => ({ idxProject: index('idx_project_status_project').on(t.projectId), }), ); // ---------------------------------------------------------------------------- // phase_gate — Gate-Review am Phasenende (Leitfaden Kap. 5 „Reviews an // Meilensteinen"): Ergebnis erreicht? Folgephase freigegeben? // ---------------------------------------------------------------------------- export const phaseGate = pgTable( 'phase_gate', { id: uuid('id') .default(sql`gen_random_uuid()`) .primaryKey(), projectId: uuid('project_id') .notNull() .references(() => project.id, { onDelete: 'cascade' }), phase: text('phase', { enum: ['definition', 'planung', 'umsetzung', 'abschluss'] }).notNull(), ergebnisErreicht: boolean('ergebnis_erreicht').notNull(), notiz: text('notiz'), reviewedBy: uuid('reviewed_by').references(() => user.id, { onDelete: 'set null' }), reviewedAt: timestamp('reviewed_at', { withTimezone: true }).notNull().defaultNow(), }, (t) => ({ idxProject: index('idx_phase_gate_project').on(t.projectId), }), ); // ---------------------------------------------------------------------------- // change_entry — Änderungs-Log (Leitfaden Kap. 5 „Mit Änderungen umgehen"): // bewusste Entscheidung über Änderungen, die das Zieldreieck bewegen. // ---------------------------------------------------------------------------- export const changeEntry = pgTable( 'change_entry', { id: uuid('id') .default(sql`gen_random_uuid()`) .primaryKey(), projectId: uuid('project_id') .notNull() .references(() => project.id, { onDelete: 'cascade' }), titel: text('titel').notNull(), auswirkung: text('auswirkung'), // Auswirkung aufs Zieldreieck entscheidung: text('entscheidung', { enum: ['offen', 'genehmigt', 'abgelehnt'] }) .notNull() .default('offen'), createdBy: uuid('created_by').references(() => user.id, { onDelete: 'set null' }), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }, (t) => ({ idxProject: index('idx_change_entry_project').on(t.projectId), }), ); // ---------------------------------------------------------------------------- // 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 TimeEntry = typeof timeEntry.$inferSelect; export type NewTimeEntry = typeof timeEntry.$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; export type Lead = typeof lead.$inferSelect; export type NewLead = typeof lead.$inferInsert; export type LeadContact = typeof leadContact.$inferSelect; export type NewLeadContact = typeof leadContact.$inferInsert; export type LeadTouchpoint = typeof leadTouchpoint.$inferSelect; export type NewLeadTouchpoint = typeof leadTouchpoint.$inferInsert; export type Stakeholder = typeof stakeholder.$inferSelect; export type NewStakeholder = typeof stakeholder.$inferInsert; export type Risk = typeof risk.$inferSelect; export type NewRisk = typeof risk.$inferInsert; export type ProjectStatus = typeof projectStatus.$inferSelect; export type NewProjectStatus = typeof projectStatus.$inferInsert; export type PhaseGate = typeof phaseGate.$inferSelect; export type NewPhaseGate = typeof phaseGate.$inferInsert; export type ChangeEntry = typeof changeEntry.$inferSelect; export type NewChangeEntry = typeof changeEntry.$inferInsert;