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>
819 lines
33 KiB
TypeScript
819 lines
33 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(),
|
||
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;
|