Files
everythingbutwork/packages/db/src/schema.ts
T
dejanandClaude Opus 4.8 66adc49e2c
ci / quality (push) Failing after 43s
ci / build (push) Has been skipped
deploy / deploy (push) Successful in 53s
feat(heute): ungeplant Erledigtes nachtragen (ENTWICKLUN-7)
Drive-by-Aufgaben ("btw, Yevgen-Security-Fix hab ich auch noch gemacht")
strukturiert erfassen statt nur im Freitext. Neues Feld todo.unplanned +
Endpoint dayEntry.addUnplanned: legt sofort ein erledigtes, als „ungeplant"
markiertes ToDo an.

- Schnell-Eingabe „Ungeplant erledigt" auf Heute (Mid-Day) und in der
  Reflexion (End-of-Day-Recall).
- "ungeplant"-Badge in Heute, Reflexion und Historie.
- historyForDate liefert das unplanned-Flag mit.
- Reflexions-Freitext bleibt für die Tages-Story.

Migration 0012 (todo.unplanned).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:09:38 +02:00

819 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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(),
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<Record<string, boolean>>()
.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<Array<{ text: string; done: boolean }>>()
.notNull()
.default([]),
anhaenge: jsonb('anhaenge')
.$type<Array<{ label: string; url: string }>>()
.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;