Initial commit: everythingbutwork MVP
ci / quality (push) Has been cancelled
ci / build (push) Has been cancelled

Internes Project-Management-Tool für ToolCTRL — ergänzend zu Plane (Tickets)
und Outline (Doku). Umfasst Puls-Übersicht, Tagesrhythmus (Lock-in + Reflexion),
Bereichs-Detail-Views (Entwicklung/Vertrieb/Marketing), Pflicht-Minimum-Check
und Blocker-Lifecycle gemäß Projektmanagement-Leitfaden v3.0.

Stack: pnpm-Monorepo · Vite + React 18 + Tailwind · Fastify + tRPC v11 +
Drizzle ORM · Postgres 16 · Authentik OIDC (Phase 2, Dev-Auto-Login aktiv).

Planungs-Artefakte unter _bmad-output/planning-artifacts/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
dejanandClaude Opus 4.7 committed 2026-05-25 22:47:26 +02:00
commit 4f33509a0f
131 files changed
+34339

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
import type { Config } from 'drizzle-kit';
export default {
schema: './src/schema.ts',
out: './migrations',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL ?? 'postgres://cockpit:cockpit_dev@localhost:55432/cockpit',
},
strict: true,
verbose: true,
} satisfies Config;
+40
View File
@@ -0,0 +1,40 @@
CREATE TABLE "audit_event" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"actor_user_id" uuid,
"action" text NOT NULL,
"entity_kind" text NOT NULL,
"entity_id" uuid,
"payload" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "session" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"ip_address" text,
"user_agent" text
);
--> statement-breakpoint
CREATE TABLE "user" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"authentik_sub" text NOT NULL,
"name" text NOT NULL,
"email" text NOT NULL,
"default_area_id" uuid,
"active" boolean DEFAULT true NOT NULL,
"abwesend_bis" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "audit_event" ADD CONSTRAINT "audit_event_actor_user_id_user_id_fk" FOREIGN KEY ("actor_user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_audit_event_actor" ON "audit_event" USING btree ("actor_user_id");--> statement-breakpoint
CREATE INDEX "idx_audit_event_action" ON "audit_event" USING btree ("action");--> statement-breakpoint
CREATE INDEX "idx_audit_event_entity" ON "audit_event" USING btree ("entity_kind","entity_id");--> statement-breakpoint
CREATE INDEX "idx_session_user_id" ON "session" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "idx_session_expires_at" ON "session" USING btree ("expires_at");--> statement-breakpoint
CREATE UNIQUE INDEX "uniq_user_authentik_sub" ON "user" USING btree ("authentik_sub");--> statement-breakpoint
CREATE INDEX "idx_user_email" ON "user" USING btree ("email");
@@ -0,0 +1,72 @@
CREATE TABLE "absence_day" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"date" date NOT NULL,
"reason" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "blocker" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"title" text NOT NULL,
"description" text,
"area_id" uuid,
"project_id" uuid,
"owner_user_id" uuid,
"opened_by" uuid NOT NULL,
"opened_at" timestamp with time zone DEFAULT now() NOT NULL,
"resolved_by" uuid,
"resolved_at" timestamp with time zone,
"resolution_text" text
);
--> statement-breakpoint
CREATE TABLE "day_entry" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"date" date NOT NULL,
"status" text NOT NULL,
"checked_in_at" timestamp with time zone,
"reflected_at" timestamp with time zone,
"reflection_text" text,
"ampel" text,
"trend" text,
"url" text,
"blocker_id" uuid,
"edit_locked_at" timestamp with time zone,
"published_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "todo" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"day_entry_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
"date" date NOT NULL,
"title" text NOT NULL,
"order" integer DEFAULT 0 NOT NULL,
"bezug_kind" text,
"bezug_id" uuid,
"status" text DEFAULT 'open' NOT NULL,
"drop_reason" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"completed_at" timestamp with time zone,
"dropped_at" timestamp with time zone
);
--> statement-breakpoint
ALTER TABLE "absence_day" ADD CONSTRAINT "absence_day_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "blocker" ADD CONSTRAINT "blocker_owner_user_id_user_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "blocker" ADD CONSTRAINT "blocker_opened_by_user_id_fk" FOREIGN KEY ("opened_by") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "blocker" ADD CONSTRAINT "blocker_resolved_by_user_id_fk" FOREIGN KEY ("resolved_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "day_entry" ADD CONSTRAINT "day_entry_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "todo" ADD CONSTRAINT "todo_day_entry_id_day_entry_id_fk" FOREIGN KEY ("day_entry_id") REFERENCES "public"."day_entry"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "todo" ADD CONSTRAINT "todo_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "uniq_absence_user_date" ON "absence_day" USING btree ("user_id","date");--> statement-breakpoint
CREATE INDEX "idx_blocker_area" ON "blocker" USING btree ("area_id");--> statement-breakpoint
CREATE INDEX "idx_blocker_project" ON "blocker" USING btree ("project_id");--> statement-breakpoint
CREATE INDEX "idx_blocker_opened_at" ON "blocker" USING btree ("opened_at");--> statement-breakpoint
CREATE UNIQUE INDEX "uniq_day_entry_user_date" ON "day_entry" USING btree ("user_id","date");--> statement-breakpoint
CREATE INDEX "idx_day_entry_date" ON "day_entry" USING btree ("date");--> statement-breakpoint
CREATE INDEX "idx_day_entry_published_at" ON "day_entry" USING btree ("published_at");--> statement-breakpoint
CREATE INDEX "idx_todo_day_entry_id" ON "todo" USING btree ("day_entry_id");--> statement-breakpoint
CREATE INDEX "idx_todo_user_date" ON "todo" USING btree ("user_id","date");
@@ -0,0 +1,53 @@
CREATE TABLE "area" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"slug" text NOT NULL,
"name" text NOT NULL,
"kind" text NOT NULL,
"order" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "milestone" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"project_id" uuid NOT NULL,
"title" text NOT NULL,
"due" date,
"status" text DEFAULT 'open' NOT NULL,
"description" text,
"order" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "project" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"area_id" uuid NOT NULL,
"name" text NOT NULL,
"kind" text NOT NULL,
"verantwortliche_user_id" uuid,
"arbeitsmodus" text,
"outline_steckbrief_url" text,
"steckbrief_freigegeben" boolean DEFAULT false NOT NULL,
"plane_project_url" text,
"risikoliste_url" text,
"dod_text" text,
"start" date,
"deadline" date,
"status_cache" text,
"trend_cache" text,
"pflicht_minimum_state" jsonb DEFAULT '{}'::jsonb NOT NULL,
"archiviert" boolean DEFAULT false NOT NULL,
"archiviert_am" timestamp with time zone,
"lessons_learned_url" text,
"created_by" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "milestone" ADD CONSTRAINT "milestone_project_id_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."project"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "project" ADD CONSTRAINT "project_area_id_area_id_fk" FOREIGN KEY ("area_id") REFERENCES "public"."area"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "project" ADD CONSTRAINT "project_verantwortliche_user_id_user_id_fk" FOREIGN KEY ("verantwortliche_user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "project" ADD CONSTRAINT "project_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "uniq_area_slug" ON "area" USING btree ("slug");--> statement-breakpoint
CREATE INDEX "idx_milestone_project" ON "milestone" USING btree ("project_id");--> statement-breakpoint
CREATE INDEX "idx_project_area" ON "project" USING btree ("area_id");--> statement-breakpoint
CREATE INDEX "idx_project_archived" ON "project" USING btree ("archiviert");
@@ -0,0 +1,54 @@
CREATE TABLE "campaign" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"area_id" uuid NOT NULL,
"name" text NOT NULL,
"status" text DEFAULT 'active' NOT NULL,
"reach" integer DEFAULT 0 NOT NULL,
"owner_user_id" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "deal" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"area_id" uuid NOT NULL,
"name" text NOT NULL,
"owner_user_id" uuid,
"value_eur" integer DEFAULT 0 NOT NULL,
"probability" integer DEFAULT 50 NOT NULL,
"stage" text NOT NULL,
"entered_stage_at" timestamp with time zone DEFAULT now() NOT NULL,
"won_at" timestamp with time zone,
"won_value_eur" integer,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "gtm_step" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"product" text NOT NULL,
"title" text NOT NULL,
"owner_user_id" uuid,
"status" text DEFAULT 'open' NOT NULL,
"order" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "lead_handover" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"campaign_id" uuid,
"deal_id" uuid,
"uebergeben_am" timestamp with time zone DEFAULT now() NOT NULL,
"uebergeben_von" uuid
);
--> statement-breakpoint
ALTER TABLE "campaign" ADD CONSTRAINT "campaign_area_id_area_id_fk" FOREIGN KEY ("area_id") REFERENCES "public"."area"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "campaign" ADD CONSTRAINT "campaign_owner_user_id_user_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "deal" ADD CONSTRAINT "deal_area_id_area_id_fk" FOREIGN KEY ("area_id") REFERENCES "public"."area"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "deal" ADD CONSTRAINT "deal_owner_user_id_user_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "gtm_step" ADD CONSTRAINT "gtm_step_owner_user_id_user_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "lead_handover" ADD CONSTRAINT "lead_handover_campaign_id_campaign_id_fk" FOREIGN KEY ("campaign_id") REFERENCES "public"."campaign"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "lead_handover" ADD CONSTRAINT "lead_handover_deal_id_deal_id_fk" FOREIGN KEY ("deal_id") REFERENCES "public"."deal"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "lead_handover" ADD CONSTRAINT "lead_handover_uebergeben_von_user_id_fk" FOREIGN KEY ("uebergeben_von") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_campaign_status" ON "campaign" USING btree ("status");--> statement-breakpoint
CREATE INDEX "idx_deal_stage" ON "deal" USING btree ("stage");--> statement-breakpoint
CREATE INDEX "idx_deal_owner" ON "deal" USING btree ("owner_user_id");--> statement-breakpoint
CREATE INDEX "idx_gtm_product" ON "gtm_step" USING btree ("product");
+3
View File
@@ -0,0 +1,3 @@
DROP INDEX "idx_user_email";--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "is_admin" boolean DEFAULT false NOT NULL;--> statement-breakpoint
CREATE UNIQUE INDEX "uniq_user_email" ON "user" USING btree ("email");
@@ -0,0 +1,2 @@
ALTER TABLE "todo" ADD COLUMN "carried_over" boolean DEFAULT false NOT NULL;--> statement-breakpoint
CREATE INDEX "idx_todo_user_status_carry" ON "todo" USING btree ("user_id","status","carried_over");
@@ -0,0 +1,333 @@
{
"id": "369573ad-730d-436d-9b47-bbd1c779db2b",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.audit_event": {
"name": "audit_event",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"actor_user_id": {
"name": "actor_user_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": true
},
"entity_kind": {
"name": "entity_kind",
"type": "text",
"primaryKey": false,
"notNull": true
},
"entity_id": {
"name": "entity_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"payload": {
"name": "payload",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"idx_audit_event_actor": {
"name": "idx_audit_event_actor",
"columns": [
{
"expression": "actor_user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_audit_event_action": {
"name": "idx_audit_event_action",
"columns": [
{
"expression": "action",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_audit_event_entity": {
"name": "idx_audit_event_entity",
"columns": [
{
"expression": "entity_kind",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "entity_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"audit_event_actor_user_id_user_id_fk": {
"name": "audit_event_actor_user_id_user_id_fk",
"tableFrom": "audit_event",
"tableTo": "user",
"columnsFrom": ["actor_user_id"],
"columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"idx_session_user_id": {
"name": "idx_session_user_id",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_session_expires_at": {
"name": "idx_session_expires_at",
"columns": [
{
"expression": "expires_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"authentik_sub": {
"name": "authentik_sub",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"default_area_id": {
"name": "default_area_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"active": {
"name": "active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"abwesend_bis": {
"name": "abwesend_bis",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"uniq_user_authentik_sub": {
"name": "uniq_user_authentik_sub",
"columns": [
{
"expression": "authentik_sub",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_user_email": {
"name": "idx_user_email",
"columns": [
{
"expression": "email",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
@@ -0,0 +1,884 @@
{
"id": "6cd1084c-f51d-4a2b-9923-f897a5e36c34",
"prevId": "369573ad-730d-436d-9b47-bbd1c779db2b",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.absence_day": {
"name": "absence_day",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"date": {
"name": "date",
"type": "date",
"primaryKey": false,
"notNull": true
},
"reason": {
"name": "reason",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"uniq_absence_user_date": {
"name": "uniq_absence_user_date",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "date",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"absence_day_user_id_user_id_fk": {
"name": "absence_day_user_id_user_id_fk",
"tableFrom": "absence_day",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.audit_event": {
"name": "audit_event",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"actor_user_id": {
"name": "actor_user_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": true
},
"entity_kind": {
"name": "entity_kind",
"type": "text",
"primaryKey": false,
"notNull": true
},
"entity_id": {
"name": "entity_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"payload": {
"name": "payload",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"idx_audit_event_actor": {
"name": "idx_audit_event_actor",
"columns": [
{
"expression": "actor_user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_audit_event_action": {
"name": "idx_audit_event_action",
"columns": [
{
"expression": "action",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_audit_event_entity": {
"name": "idx_audit_event_entity",
"columns": [
{
"expression": "entity_kind",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "entity_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"audit_event_actor_user_id_user_id_fk": {
"name": "audit_event_actor_user_id_user_id_fk",
"tableFrom": "audit_event",
"tableTo": "user",
"columnsFrom": ["actor_user_id"],
"columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.blocker": {
"name": "blocker",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"area_id": {
"name": "area_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"project_id": {
"name": "project_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"owner_user_id": {
"name": "owner_user_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"opened_by": {
"name": "opened_by",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"opened_at": {
"name": "opened_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"resolved_by": {
"name": "resolved_by",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"resolved_at": {
"name": "resolved_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"resolution_text": {
"name": "resolution_text",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"idx_blocker_area": {
"name": "idx_blocker_area",
"columns": [
{
"expression": "area_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_blocker_project": {
"name": "idx_blocker_project",
"columns": [
{
"expression": "project_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_blocker_opened_at": {
"name": "idx_blocker_opened_at",
"columns": [
{
"expression": "opened_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"blocker_owner_user_id_user_id_fk": {
"name": "blocker_owner_user_id_user_id_fk",
"tableFrom": "blocker",
"tableTo": "user",
"columnsFrom": ["owner_user_id"],
"columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
},
"blocker_opened_by_user_id_fk": {
"name": "blocker_opened_by_user_id_fk",
"tableFrom": "blocker",
"tableTo": "user",
"columnsFrom": ["opened_by"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"blocker_resolved_by_user_id_fk": {
"name": "blocker_resolved_by_user_id_fk",
"tableFrom": "blocker",
"tableTo": "user",
"columnsFrom": ["resolved_by"],
"columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.day_entry": {
"name": "day_entry",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"date": {
"name": "date",
"type": "date",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true
},
"checked_in_at": {
"name": "checked_in_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"reflected_at": {
"name": "reflected_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"reflection_text": {
"name": "reflection_text",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ampel": {
"name": "ampel",
"type": "text",
"primaryKey": false,
"notNull": false
},
"trend": {
"name": "trend",
"type": "text",
"primaryKey": false,
"notNull": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"blocker_id": {
"name": "blocker_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"edit_locked_at": {
"name": "edit_locked_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"published_at": {
"name": "published_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"uniq_day_entry_user_date": {
"name": "uniq_day_entry_user_date",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "date",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_day_entry_date": {
"name": "idx_day_entry_date",
"columns": [
{
"expression": "date",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_day_entry_published_at": {
"name": "idx_day_entry_published_at",
"columns": [
{
"expression": "published_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"day_entry_user_id_user_id_fk": {
"name": "day_entry_user_id_user_id_fk",
"tableFrom": "day_entry",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"idx_session_user_id": {
"name": "idx_session_user_id",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_session_expires_at": {
"name": "idx_session_expires_at",
"columns": [
{
"expression": "expires_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.todo": {
"name": "todo",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"day_entry_id": {
"name": "day_entry_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"date": {
"name": "date",
"type": "date",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"order": {
"name": "order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"bezug_kind": {
"name": "bezug_kind",
"type": "text",
"primaryKey": false,
"notNull": false
},
"bezug_id": {
"name": "bezug_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'open'"
},
"drop_reason": {
"name": "drop_reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"completed_at": {
"name": "completed_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"dropped_at": {
"name": "dropped_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"idx_todo_day_entry_id": {
"name": "idx_todo_day_entry_id",
"columns": [
{
"expression": "day_entry_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_todo_user_date": {
"name": "idx_todo_user_date",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "date",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"todo_day_entry_id_day_entry_id_fk": {
"name": "todo_day_entry_id_day_entry_id_fk",
"tableFrom": "todo",
"tableTo": "day_entry",
"columnsFrom": ["day_entry_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"todo_user_id_user_id_fk": {
"name": "todo_user_id_user_id_fk",
"tableFrom": "todo",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"authentik_sub": {
"name": "authentik_sub",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"default_area_id": {
"name": "default_area_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"active": {
"name": "active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"abwesend_bis": {
"name": "abwesend_bis",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"uniq_user_authentik_sub": {
"name": "uniq_user_authentik_sub",
"columns": [
{
"expression": "authentik_sub",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_user_email": {
"name": "idx_user_email",
"columns": [
{
"expression": "email",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+48
View File
@@ -0,0 +1,48 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1779736070490,
"tag": "0000_init",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1779737202483,
"tag": "0001_day_rhythm",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1779737584310,
"tag": "0002_areas_projects",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1779737764727,
"tag": "0003_sales_marketing",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1779739115697,
"tag": "0004_is_admin",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1779740456944,
"tag": "0005_todo_carry_over",
"breakpoints": true
}
]
}
+33
View File
@@ -0,0 +1,33 @@
{
"name": "@cockpit/db",
"private": true,
"version": "0.0.0",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./schema": "./src/schema.ts",
"./client": "./src/client.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"build": "tsc",
"test": "vitest run --passWithNoTests",
"migrate:generate": "drizzle-kit generate",
"migrate:up": "drizzle-kit migrate",
"seed": "tsx src/seed.ts",
"studio": "drizzle-kit studio"
},
"dependencies": {
"drizzle-orm": "^0.38.2",
"pg": "^8.13.1"
},
"devDependencies": {
"@types/pg": "^8.11.10",
"drizzle-kit": "^0.30.1",
"tsx": "^4.19.2",
"typescript": "^5.7.2",
"vitest": "^2.1.8"
}
}
+23
View File
@@ -0,0 +1,23 @@
import { drizzle, type NodePgDatabase } from 'drizzle-orm/node-postgres';
import pg from 'pg';
import * as schema from './schema.js';
export type Db = NodePgDatabase<typeof schema>;
export interface CreateDbClientOptions {
connectionString: string;
poolSize?: number;
}
export function createDbClient(options: CreateDbClientOptions): {
db: Db;
pool: pg.Pool;
} {
const pool = new pg.Pool({
connectionString: options.connectionString,
max: options.poolSize ?? 10,
});
const db = drizzle(pool, { schema });
return { db, pool };
}
+5
View File
@@ -0,0 +1,5 @@
export * from './schema.js';
export { createDbClient } from './client.js';
export type { Db, CreateDbClientOptions } from './client.js';
export const DB_VERSION = '0.0.1';
+440
View File
@@ -0,0 +1,440 @@
// Drizzle-Schema — Single Source of Truth.
// Wird inkrementell pro Story erweitert. In Story 1.2 nur Fundament:
// user, session, audit_event.
import { sql } from 'drizzle-orm';
import {
pgTable,
text,
uuid,
boolean,
timestamp,
date,
integer,
jsonb,
index,
uniqueIndex,
} from 'drizzle-orm/pg-core';
// ----------------------------------------------------------------------------
// area — fachlicher Bereich (Entwicklung, Vertrieb, Marketing, Allgemein).
// ----------------------------------------------------------------------------
export const area = pgTable(
'area',
{
id: uuid('id')
.default(sql`gen_random_uuid()`)
.primaryKey(),
slug: text('slug').notNull(),
name: text('name').notNull(),
kind: text('kind', { enum: ['dev', 'sales', 'marketing', 'allgemein'] }).notNull(),
order: integer('order').notNull().default(0),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => ({
uniqSlug: uniqueIndex('uniq_area_slug').on(t.slug),
}),
);
// ----------------------------------------------------------------------------
// user — Mitarbeiter. Identität kommt aus Authentik (sub) oder ist lokal in
// Dev/Test angelegt.
// ----------------------------------------------------------------------------
export const user = pgTable(
'user',
{
id: uuid('id')
.default(sql`gen_random_uuid()`)
.primaryKey(),
authentikSub: text('authentik_sub').notNull(),
name: text('name').notNull(),
email: text('email').notNull(),
defaultAreaId: uuid('default_area_id'),
isAdmin: boolean('is_admin').notNull().default(false),
active: boolean('active').notNull().default(true),
abwesendBis: timestamp('abwesend_bis', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => ({
uniqAuthentikSub: uniqueIndex('uniq_user_authentik_sub').on(t.authentikSub),
uniqEmail: uniqueIndex('uniq_user_email').on(t.email),
}),
);
// ----------------------------------------------------------------------------
// session — Server-Side Session. Browser bekommt nur `id` als HttpOnly-Cookie.
// ----------------------------------------------------------------------------
export const session = pgTable(
'session',
{
id: uuid('id')
.default(sql`gen_random_uuid()`)
.primaryKey(),
userId: uuid('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
},
(t) => ({
idxUserId: index('idx_session_user_id').on(t.userId),
idxExpiresAt: index('idx_session_expires_at').on(t.expiresAt),
}),
);
// ----------------------------------------------------------------------------
// audit_event — Audit-Trail für jede mutierende Operation. Gefüllt von der
// `withAudit`-Middleware (Story 1.4).
// ----------------------------------------------------------------------------
export const auditEvent = pgTable(
'audit_event',
{
id: uuid('id')
.default(sql`gen_random_uuid()`)
.primaryKey(),
actorUserId: uuid('actor_user_id').references(() => user.id, {
onDelete: 'set null',
}),
action: text('action').notNull(), // e.g. 'day_entry.reflection_submitted'
entityKind: text('entity_kind').notNull(), // e.g. 'day_entry'
entityId: uuid('entity_id'),
payload: jsonb('payload').$type<Record<string, unknown>>().notNull().default({}),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => ({
idxActor: index('idx_audit_event_actor').on(t.actorUserId),
idxAction: index('idx_audit_event_action').on(t.action),
idxEntity: index('idx_audit_event_entity').on(t.entityKind, t.entityId),
}),
);
// ----------------------------------------------------------------------------
// project — Projekt im Sichtmodell. Wahrheit lebt im Outline-Steckbrief.
// ----------------------------------------------------------------------------
export const project = pgTable(
'project',
{
id: uuid('id')
.default(sql`gen_random_uuid()`)
.primaryKey(),
areaId: uuid('area_id')
.notNull()
.references(() => area.id, { onDelete: 'restrict' }),
name: text('name').notNull(),
kind: text('kind', { enum: ['kundenprojekt', 'internes_projekt'] }).notNull(),
verantwortlicheUserId: uuid('verantwortliche_user_id').references(() => user.id, {
onDelete: 'set null',
}),
arbeitsmodus: text('arbeitsmodus', { enum: ['cycle', 'kanban'] }),
outlineSteckbriefUrl: text('outline_steckbrief_url'),
steckbriefFreigegeben: boolean('steckbrief_freigegeben').notNull().default(false),
planeProjectUrl: text('plane_project_url'),
risikolisteUrl: text('risikoliste_url'),
dodText: text('dod_text'),
start: date('start'),
deadline: date('deadline'),
statusCache: text('status_cache', { enum: ['green', 'amber', 'red'] }),
trendCache: text('trend_cache', { enum: ['besser', 'gleich', 'schlechter'] }),
pflichtMinimumState: jsonb('pflicht_minimum_state')
.$type<Record<string, boolean>>()
.notNull()
.default({}),
archiviert: boolean('archiviert').notNull().default(false),
archiviertAm: timestamp('archiviert_am', { withTimezone: true }),
lessonsLearnedUrl: text('lessons_learned_url'),
createdBy: uuid('created_by').references(() => user.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => ({
idxArea: index('idx_project_area').on(t.areaId),
idxArchived: index('idx_project_archived').on(t.archiviert),
}),
);
// ----------------------------------------------------------------------------
// milestone — Sicht-Meilenstein pro Projekt.
// ----------------------------------------------------------------------------
export const milestone = pgTable(
'milestone',
{
id: uuid('id')
.default(sql`gen_random_uuid()`)
.primaryKey(),
projectId: uuid('project_id')
.notNull()
.references(() => project.id, { onDelete: 'cascade' }),
title: text('title').notNull(),
due: date('due'),
status: text('status', { enum: ['open', 'done', 'overdue'] })
.notNull()
.default('open'),
description: text('description'),
order: integer('order').notNull().default(0),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => ({
idxProject: index('idx_milestone_project').on(t.projectId),
}),
);
// ----------------------------------------------------------------------------
// day_entry — der zweistufige Tageseintrag (Morgen-Einstempeln + Abend-Reflexion).
// Sichtbarkeit: tagsüber privat; ab `published_at` für Kollegen sichtbar.
// ----------------------------------------------------------------------------
export const dayEntry = pgTable(
'day_entry',
{
id: uuid('id')
.default(sql`gen_random_uuid()`)
.primaryKey(),
userId: uuid('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
date: date('date').notNull(), // YYYY-MM-DD im Europe/Berlin
status: text('status', { enum: ['begun', 'full', 'absent'] }).notNull(),
checkedInAt: timestamp('checked_in_at', { withTimezone: true }),
reflectedAt: timestamp('reflected_at', { withTimezone: true }),
reflectionText: text('reflection_text'),
ampel: text('ampel', { enum: ['green', 'amber', 'red'] }),
trend: text('trend', { enum: ['besser', 'gleich', 'schlechter'] }),
url: text('url'),
blockerId: uuid('blocker_id'),
editLockedAt: timestamp('edit_locked_at', { withTimezone: true }),
publishedAt: timestamp('published_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => ({
uniqUserDate: uniqueIndex('uniq_day_entry_user_date').on(t.userId, t.date),
idxDate: index('idx_day_entry_date').on(t.date),
idxPublishedAt: index('idx_day_entry_published_at').on(t.publishedAt),
}),
);
// ----------------------------------------------------------------------------
// todo — Tagesplan-Item. Gehört zu einem day_entry.
// ----------------------------------------------------------------------------
export const todo = pgTable(
'todo',
{
id: uuid('id')
.default(sql`gen_random_uuid()`)
.primaryKey(),
dayEntryId: uuid('day_entry_id')
.notNull()
.references(() => dayEntry.id, { onDelete: 'cascade' }),
userId: uuid('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
date: date('date').notNull(),
title: text('title').notNull(),
order: integer('order').notNull().default(0),
bezugKind: text('bezug_kind', { enum: ['project', 'deal', 'campaign', 'allgemein'] }),
bezugId: uuid('bezug_id'),
status: text('status', { enum: ['open', 'done', 'dropped'] })
.notNull()
.default('open'),
dropReason: text('drop_reason'),
carriedOver: boolean('carried_over').notNull().default(false),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
completedAt: timestamp('completed_at', { withTimezone: true }),
droppedAt: timestamp('dropped_at', { withTimezone: true }),
},
(t) => ({
idxDayEntry: index('idx_todo_day_entry_id').on(t.dayEntryId),
idxUserDate: index('idx_todo_user_date').on(t.userId, t.date),
idxUserCarry: index('idx_todo_user_status_carry').on(t.userId, t.status, t.carriedOver),
}),
);
// ----------------------------------------------------------------------------
// absence_day — Markiert einen Werktag als „abwesend" (Urlaub/Krank).
// Zählt nicht gegen die Streak.
// ----------------------------------------------------------------------------
export const absenceDay = pgTable(
'absence_day',
{
id: uuid('id')
.default(sql`gen_random_uuid()`)
.primaryKey(),
userId: uuid('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
date: date('date').notNull(),
reason: text('reason', { enum: ['urlaub', 'krank', 'sonstiges'] }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => ({
uniqUserDate: uniqueIndex('uniq_absence_user_date').on(t.userId, t.date),
}),
);
// ----------------------------------------------------------------------------
// deal — Vertriebs-Pipeline-Eintrag.
// ----------------------------------------------------------------------------
export const deal = pgTable(
'deal',
{
id: uuid('id')
.default(sql`gen_random_uuid()`)
.primaryKey(),
areaId: uuid('area_id')
.notNull()
.references(() => area.id, { onDelete: 'restrict' }),
name: text('name').notNull(),
ownerUserId: uuid('owner_user_id').references(() => user.id, { onDelete: 'set null' }),
valueEur: integer('value_eur').notNull().default(0), // Cent
probability: integer('probability').notNull().default(50), // 0-100
stage: text('stage', {
enum: ['lead', 'erstgespraech', 'demo', 'angebot', 'abschluss'],
}).notNull(),
enteredStageAt: timestamp('entered_stage_at', { withTimezone: true }).notNull().defaultNow(),
wonAt: timestamp('won_at', { withTimezone: true }),
wonValueEur: integer('won_value_eur'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => ({
idxStage: index('idx_deal_stage').on(t.stage),
idxOwner: index('idx_deal_owner').on(t.ownerUserId),
}),
);
// ----------------------------------------------------------------------------
// gtm_step — Go-to-Market-Aufgabe pro Produkt.
// ----------------------------------------------------------------------------
export const gtmStep = pgTable(
'gtm_step',
{
id: uuid('id')
.default(sql`gen_random_uuid()`)
.primaryKey(),
product: text('product').notNull(),
title: text('title').notNull(),
ownerUserId: uuid('owner_user_id').references(() => user.id, { onDelete: 'set null' }),
status: text('status', { enum: ['open', 'in_progress', 'done'] })
.notNull()
.default('open'),
order: integer('order').notNull().default(0),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => ({
idxProduct: index('idx_gtm_product').on(t.product),
}),
);
// ----------------------------------------------------------------------------
// campaign — Marketing-Kampagne.
// ----------------------------------------------------------------------------
export const campaign = pgTable(
'campaign',
{
id: uuid('id')
.default(sql`gen_random_uuid()`)
.primaryKey(),
areaId: uuid('area_id')
.notNull()
.references(() => area.id, { onDelete: 'restrict' }),
name: text('name').notNull(),
status: text('status', { enum: ['active', 'paused', 'done'] })
.notNull()
.default('active'),
reach: integer('reach').notNull().default(0),
ownerUserId: uuid('owner_user_id').references(() => user.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => ({
idxStatus: index('idx_campaign_status').on(t.status),
}),
);
// ----------------------------------------------------------------------------
// lead_handover — Marketing → Sales Übergabe (Crosslink-Banner).
// ----------------------------------------------------------------------------
export const leadHandover = pgTable('lead_handover', {
id: uuid('id')
.default(sql`gen_random_uuid()`)
.primaryKey(),
campaignId: uuid('campaign_id').references(() => campaign.id, { onDelete: 'set null' }),
dealId: uuid('deal_id').references(() => deal.id, { onDelete: 'set null' }),
uebergebenAm: timestamp('uebergeben_am', { withTimezone: true }).notNull().defaultNow(),
uebergebenVon: uuid('uebergeben_von').references(() => user.id, { onDelete: 'set null' }),
});
// ----------------------------------------------------------------------------
// blocker — bereichsübergreifendes Hindernis. Wird inline in der Reflexion
// oder über „Neuer Blocker"-Button (Story 5.3) erfasst.
// ----------------------------------------------------------------------------
export const blocker = pgTable(
'blocker',
{
id: uuid('id')
.default(sql`gen_random_uuid()`)
.primaryKey(),
title: text('title').notNull(),
description: text('description'),
areaId: uuid('area_id'),
projectId: uuid('project_id'),
ownerUserId: uuid('owner_user_id').references(() => user.id, { onDelete: 'set null' }),
openedBy: uuid('opened_by')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
openedAt: timestamp('opened_at', { withTimezone: true }).notNull().defaultNow(),
resolvedBy: uuid('resolved_by').references(() => user.id, { onDelete: 'set null' }),
resolvedAt: timestamp('resolved_at', { withTimezone: true }),
resolutionText: text('resolution_text'),
},
(t) => ({
idxArea: index('idx_blocker_area').on(t.areaId),
idxProject: index('idx_blocker_project').on(t.projectId),
idxOpenedAt: index('idx_blocker_opened_at').on(t.openedAt),
}),
);
// ----------------------------------------------------------------------------
// Type-Helper für Code, der mit den Entitäten arbeitet.
// ----------------------------------------------------------------------------
export type User = typeof user.$inferSelect;
export type NewUser = typeof user.$inferInsert;
export type Session = typeof session.$inferSelect;
export type NewSession = typeof session.$inferInsert;
export type AuditEvent = typeof auditEvent.$inferSelect;
export type NewAuditEvent = typeof auditEvent.$inferInsert;
export type DayEntry = typeof dayEntry.$inferSelect;
export type NewDayEntry = typeof dayEntry.$inferInsert;
export type Todo = typeof todo.$inferSelect;
export type NewTodo = typeof todo.$inferInsert;
export type AbsenceDay = typeof absenceDay.$inferSelect;
export type NewAbsenceDay = typeof absenceDay.$inferInsert;
export type Blocker = typeof blocker.$inferSelect;
export type NewBlocker = typeof blocker.$inferInsert;
export type Area = typeof area.$inferSelect;
export type NewArea = typeof area.$inferInsert;
export type Project = typeof project.$inferSelect;
export type NewProject = typeof project.$inferInsert;
export type Milestone = typeof milestone.$inferSelect;
export type NewMilestone = typeof milestone.$inferInsert;
export type Deal = typeof deal.$inferSelect;
export type NewDeal = typeof deal.$inferInsert;
export type GtmStep = typeof gtmStep.$inferSelect;
export type NewGtmStep = typeof gtmStep.$inferInsert;
export type Campaign = typeof campaign.$inferSelect;
export type NewCampaign = typeof campaign.$inferInsert;
export type LeadHandover = typeof leadHandover.$inferSelect;
export type NewLeadHandover = typeof leadHandover.$inferInsert;
+388
View File
@@ -0,0 +1,388 @@
// Seed-Skript: idempotent. Legt Areas, Test-Mitarbeiter (inkl. Admin Dejan) und
// Dummy-Daten (Projekte, Deals, Kampagnen, Tageseinträge, Blocker) an.
import { sql } from 'drizzle-orm';
import { createDbClient } from './client.js';
import {
area,
user,
project,
milestone,
deal,
campaign,
leadHandover,
gtmStep,
dayEntry,
todo,
blocker,
} from './schema.js';
const AREAS = [
{ slug: 'entwicklung', name: 'Entwicklung', kind: 'dev' as const, order: 1 },
{ slug: 'vertrieb', name: 'Vertrieb', kind: 'sales' as const, order: 2 },
{ slug: 'marketing', name: 'Marketing', kind: 'marketing' as const, order: 3 },
{ slug: 'allgemein', name: 'Allgemein', kind: 'allgemein' as const, order: 99 },
];
const USERS = [
{
authentikSub: 'local-dejan',
name: 'Dejan Cosic',
email: 'dejan@tool-ctrl.de',
isAdmin: true,
defaultAreaSlug: 'entwicklung',
},
{
authentikSub: 'local-lukas',
name: 'Lukas Berger',
email: 'lukas@tool-ctrl.de',
isAdmin: false,
defaultAreaSlug: 'entwicklung',
},
{
authentikSub: 'local-anna',
name: 'Anna Heinrich',
email: 'anna@tool-ctrl.de',
isAdmin: false,
defaultAreaSlug: 'vertrieb',
},
{
authentikSub: 'local-marco',
name: 'Marco Diaz',
email: 'marco@tool-ctrl.de',
isAdmin: false,
defaultAreaSlug: 'marketing',
},
{
authentikSub: 'local-jana',
name: 'Jana Müller',
email: 'jana@tool-ctrl.de',
isAdmin: false,
defaultAreaSlug: 'allgemein',
},
];
function isoDateOffset(days: number): string {
const d = new Date();
d.setDate(d.getDate() + days);
return d.toISOString().slice(0, 10);
}
function pickWerktag(daysBack: number): string {
const cur = new Date();
cur.setDate(cur.getDate() - daysBack);
while (cur.getUTCDay() === 0 || cur.getUTCDay() === 6) {
cur.setDate(cur.getDate() - 1);
}
return cur.toISOString().slice(0, 10);
}
export async function seed(connectionString: string): Promise<void> {
const { db, pool } = createDbClient({ connectionString });
try {
// ----- Areas -----
await db.insert(area).values(AREAS).onConflictDoNothing();
const areas = await db.select().from(area);
const areaBySlug = Object.fromEntries(areas.map((a) => [a.slug, a]));
// ----- Users -----
for (const u of USERS) {
const defaultArea = areaBySlug[u.defaultAreaSlug];
await db
.insert(user)
.values({
authentikSub: u.authentikSub,
name: u.name,
email: u.email,
isAdmin: u.isAdmin,
defaultAreaId: defaultArea?.id ?? null,
})
.onConflictDoUpdate({
target: user.authentikSub,
set: {
name: u.name,
email: u.email,
isAdmin: u.isAdmin,
defaultAreaId: defaultArea?.id ?? null,
updatedAt: new Date(),
},
});
}
const users = await db.select().from(user);
const byEmail = (m: string) => users.find((x) => x.email === m)!;
const dejan = byEmail('dejan@tool-ctrl.de');
const lukas = byEmail('lukas@tool-ctrl.de');
const anna = byEmail('anna@tool-ctrl.de');
const marco = byEmail('marco@tool-ctrl.de');
// ----- Projects -----
const existingProjects = await db.select().from(project);
if (existingProjects.length === 0) {
const ent = areaBySlug.entwicklung!;
const [proj1] = await db
.insert(project)
.values({
areaId: ent.id,
name: 'Schmidt & Söhne Auftragsportal',
kind: 'kundenprojekt',
verantwortlicheUserId: dejan.id,
arbeitsmodus: 'cycle',
outlineSteckbriefUrl: 'https://outline.tool-ctrl.de/projekte/schmidt-soehne',
steckbriefFreigegeben: true,
planeProjectUrl: 'https://plane.tool-ctrl.de/projects/schmidt',
dodText: 'Portal live, mind. 3 Auftragstypen, Stripe-Anbindung getestet.',
start: isoDateOffset(-30),
deadline: isoDateOffset(35),
statusCache: 'amber',
trendCache: 'gleich',
pflichtMinimumState: {
steckbrief_freigegeben: true,
verantwortlicher: true,
plane_board: true,
meilensteine: true,
status_aktuell: true,
retrospektive: false,
},
createdBy: dejan.id,
})
.returning();
const [proj2] = await db
.insert(project)
.values({
areaId: ent.id,
name: 'everythingbutwork Rollout intern',
kind: 'internes_projekt',
verantwortlicheUserId: dejan.id,
arbeitsmodus: 'kanban',
outlineSteckbriefUrl: 'https://outline.tool-ctrl.de/projekte/everythingbutwork-rollout',
steckbriefFreigegeben: false,
planeProjectUrl: 'https://plane.tool-ctrl.de/projects/everythingbutwork',
start: isoDateOffset(-7),
deadline: isoDateOffset(45),
statusCache: 'green',
trendCache: 'besser',
pflichtMinimumState: {
steckbrief_freigegeben: false,
verantwortlicher: true,
plane_board: true,
meilensteine: true,
status_aktuell: true,
retrospektive: false,
},
createdBy: dejan.id,
})
.returning();
const [proj3] = await db
.insert(project)
.values({
areaId: ent.id,
name: 'Kalender-Sync v2',
kind: 'internes_projekt',
verantwortlicheUserId: lukas.id,
arbeitsmodus: 'kanban',
outlineSteckbriefUrl: 'https://outline.tool-ctrl.de/projekte/kalender-v2',
steckbriefFreigegeben: true,
planeProjectUrl: 'https://plane.tool-ctrl.de/projects/kalender',
start: isoDateOffset(-14),
deadline: isoDateOffset(7),
statusCache: 'red',
trendCache: 'schlechter',
pflichtMinimumState: {
steckbrief_freigegeben: true,
verantwortlicher: true,
plane_board: true,
meilensteine: false,
status_aktuell: true,
retrospektive: false,
},
createdBy: dejan.id,
})
.returning();
if (proj1 && proj2 && proj3) {
await db.insert(milestone).values([
{ projectId: proj1.id, title: 'Auftragsformular Phase 1', due: isoDateOffset(-5), status: 'done', order: 0 },
{ projectId: proj1.id, title: 'Stripe Webhook-Tests', due: isoDateOffset(10), status: 'open', order: 1 },
{ projectId: proj1.id, title: 'Pilot mit Kunde', due: isoDateOffset(25), status: 'open', order: 2 },
{ projectId: proj2.id, title: 'Tagesrhythmus produktiv', due: isoDateOffset(5), status: 'open', order: 0 },
{ projectId: proj2.id, title: 'Pflicht-Minimum vollständig', due: isoDateOffset(20), status: 'open', order: 1 },
{ projectId: proj3.id, title: 'Token-Refresh Bugfix', due: isoDateOffset(-3), status: 'overdue', order: 0 },
]);
}
}
// ----- Deals -----
const existingDeals = await db.select().from(deal);
if (existingDeals.length === 0) {
const sales = areaBySlug.vertrieb!;
await db.insert(deal).values([
{ areaId: sales.id, name: 'Schmidt & Söhne Wartungsvertrag', ownerUserId: anna.id, valueEur: 1_200_000, probability: 70, stage: 'angebot' },
{ areaId: sales.id, name: 'Bauwerk AG Plattform', ownerUserId: anna.id, valueEur: 850_000, probability: 50, stage: 'demo' },
{ areaId: sales.id, name: 'Müller GmbH Onboarding', ownerUserId: anna.id, valueEur: 250_000, probability: 30, stage: 'erstgespraech' },
{ areaId: sales.id, name: 'Hartmann KG Pilot', ownerUserId: anna.id, valueEur: 180_000, probability: 80, stage: 'angebot' },
{
areaId: sales.id,
name: 'Becker Logistik Phase 1',
ownerUserId: anna.id,
valueEur: 950_000,
probability: 100,
stage: 'abschluss',
wonAt: new Date(),
wonValueEur: 950_000,
},
]);
}
// ----- GTM -----
const existingGtm = await db.select().from(gtmStep);
if (existingGtm.length === 0) {
await db.insert(gtmStep).values([
{ product: 'ToolCTRL Suite', title: 'Landingpage live', ownerUserId: marco.id, status: 'done', order: 0 },
{ product: 'ToolCTRL Suite', title: 'Pricing-Sheet final', ownerUserId: anna.id, status: 'in_progress', order: 1 },
{ product: 'ToolCTRL Suite', title: 'Demo-Video produzieren', ownerUserId: marco.id, status: 'open', order: 2 },
]);
}
// ----- Campaigns + LeadHandover -----
const existingCampaigns = await db.select().from(campaign);
if (existingCampaigns.length === 0) {
const mkt = areaBySlug.marketing!;
const [c1, c2] = await db
.insert(campaign)
.values([
{ areaId: mkt.id, name: 'LinkedIn Q2 Outbound', status: 'active', reach: 18500, ownerUserId: marco.id },
{ areaId: mkt.id, name: 'Webinar-Serie „Workflows"', status: 'active', reach: 4200, ownerUserId: marco.id },
{ areaId: mkt.id, name: 'Newsletter Spring', status: 'done', reach: 2200, ownerUserId: marco.id },
])
.returning();
if (c1 && c2) {
await db.insert(leadHandover).values([
{ campaignId: c1.id, uebergebenVon: marco.id },
{ campaignId: c1.id, uebergebenVon: marco.id },
{ campaignId: c1.id, uebergebenVon: marco.id },
{ campaignId: c2.id, uebergebenVon: marco.id },
]);
}
}
// ----- DayEntries + Todos -----
const existingEntries = await db.select().from(dayEntry);
if (existingEntries.length === 0) {
const yesterday = pickWerktag(1);
const twoDays = pickWerktag(2);
const [d1] = await db
.insert(dayEntry)
.values({
userId: dejan.id,
date: yesterday,
status: 'full',
checkedInAt: new Date(`${yesterday}T08:30:00+02:00`),
reflectedAt: new Date(`${yesterday}T17:30:00+02:00`),
reflectionText: 'Stripe-Webhook-Setup steht. Pilot-Demo morgen vorbereitet.',
ampel: 'green',
trend: 'besser',
publishedAt: new Date(),
})
.returning();
if (d1) {
await db.insert(todo).values([
{ dayEntryId: d1.id, userId: dejan.id, date: yesterday, title: 'Stripe-Webhook implementieren', order: 0, status: 'done', bezugKind: 'project' },
{ dayEntryId: d1.id, userId: dejan.id, date: yesterday, title: 'Pilot-Demo vorbereiten', order: 1, status: 'done', bezugKind: 'project' },
{ dayEntryId: d1.id, userId: dejan.id, date: yesterday, title: 'Marketing-Briefing review', order: 2, status: 'dropped', dropReason: 'Marco war krank' },
]);
}
const [d2] = await db
.insert(dayEntry)
.values({
userId: lukas.id,
date: yesterday,
status: 'full',
checkedInAt: new Date(`${yesterday}T09:00:00+02:00`),
reflectedAt: new Date(`${yesterday}T18:00:00+02:00`),
reflectionText: 'Token-Refresh Bug reproduziert, Fix-Hypothese steht. Komme morgen weiter.',
ampel: 'red',
trend: 'schlechter',
publishedAt: new Date(),
})
.returning();
if (d2) {
await db.insert(todo).values([
{ dayEntryId: d2.id, userId: lukas.id, date: yesterday, title: 'Token-Refresh debuggen', order: 0, status: 'open', bezugKind: 'project' },
{ dayEntryId: d2.id, userId: lukas.id, date: yesterday, title: 'PR Review für Anna', order: 1, status: 'done' },
]);
}
const [d3] = await db
.insert(dayEntry)
.values({
userId: anna.id,
date: yesterday,
status: 'full',
checkedInAt: new Date(`${yesterday}T08:15:00+02:00`),
reflectedAt: new Date(`${yesterday}T16:45:00+02:00`),
reflectionText: 'Hartmann-Angebot rausgeschickt. Schmidt-Wartung nächste Woche Demo.',
ampel: 'green',
trend: 'gleich',
publishedAt: new Date(),
})
.returning();
if (d3) {
await db.insert(todo).values([
{ dayEntryId: d3.id, userId: anna.id, date: yesterday, title: 'Hartmann-Angebot rausschicken', order: 0, status: 'done', bezugKind: 'deal' },
{ dayEntryId: d3.id, userId: anna.id, date: yesterday, title: 'Demo-Termin Schmidt fixieren', order: 1, status: 'done', bezugKind: 'deal' },
]);
}
// Marco hat sich vor 2 Tagen eingestempelt, aber nie reflektiert
await db.insert(dayEntry).values({
userId: marco.id,
date: twoDays,
status: 'begun',
checkedInAt: new Date(`${twoDays}T10:00:00+02:00`),
publishedAt: new Date(),
});
}
// ----- Blockers -----
const existingBlockers = await db.select().from(blocker);
if (existingBlockers.length === 0) {
await db.insert(blocker).values([
{
title: 'Plane-API-Token läuft regelmäßig ab',
description: 'Issue-Sync zum Plane bricht alle 24h, brauchen Refresh-Pattern.',
openedBy: lukas.id,
ownerUserId: dejan.id,
},
{
title: 'Kunde Schmidt liefert Stripe-Account-Daten nicht',
description: 'Seit 5 Tagen Nachfrage offen, Pilot kann ohne nicht starten.',
openedBy: dejan.id,
ownerUserId: anna.id,
},
]);
}
await db.update(user).set({ updatedAt: new Date() }).where(sql`true`);
} finally {
await pool.end();
}
}
const isMain = import.meta.url === `file://${process.argv[1]}`;
if (isMain) {
const url = process.env.DATABASE_URL ?? 'postgres://cockpit:cockpit_dev@localhost:55432/cockpit';
seed(url).then(
() => {
console.log('Seed complete.');
process.exit(0);
},
(err) => {
console.error(err);
process.exit(1);
},
);
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"noEmit": true,
"types": ["node"]
},
"include": ["src"]
}
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@cockpit/shared",
"private": true,
"version": "0.0.0",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./schemas/*": "./src/schemas/*.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"build": "tsc",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"zod": "^3.24.1"
},
"devDependencies": {
"typescript": "^5.7.2",
"vitest": "^2.1.8"
}
}
+8
View File
@@ -0,0 +1,8 @@
// Konstanten, die zwischen Frontend und Backend identisch genutzt werden.
export const MAX_TODO_TEXT_LENGTH = 200;
export const MAX_REFLECTION_TEXT_LENGTH = 1000;
export const WERKTAGE = ['MO', 'DI', 'MI', 'DO', 'FR'] as const;
export const JOURFIXE_DAY = 'DI' as const; // Di 11:00 als Default
export const JOURFIXE_HOUR = 11;
export const REFLEXION_REMINDER_HOUR = 17;
+7
View File
@@ -0,0 +1,7 @@
export * from './constants.js';
export * as DayEntrySchemas from './schemas/day-entry.js';
export * as ProjectSchemas from './schemas/project.js';
export * as DealSchemas from './schemas/deal.js';
export * as CampaignSchemas from './schemas/campaign.js';
export const SHARED_VERSION = '0.0.2';
+9
View File
@@ -0,0 +1,9 @@
import { z } from 'zod';
export const createCampaignInput = z.object({
name: z.string().min(1).max(200),
status: z.enum(['active', 'paused', 'done']).default('active'),
reach: z.number().int().min(0).default(0),
ownerUserId: z.string().uuid().optional(),
});
export type CreateCampaignInput = z.infer<typeof createCampaignInput>;
+59
View File
@@ -0,0 +1,59 @@
import { z } from 'zod';
export const ampelSchema = z.enum(['green', 'amber', 'red']);
export const trendSchema = z.enum(['besser', 'gleich', 'schlechter']);
export const bezugKindSchema = z.enum(['project', 'deal', 'campaign', 'allgemein']);
export const todoStatusSchema = z.enum(['open', 'done', 'dropped']);
export const todoInputSchema = z.object({
title: z.string().min(1).max(200),
bezugKind: bezugKindSchema.optional(),
bezugId: z.string().uuid().optional(),
});
export type TodoInput = z.infer<typeof todoInputSchema>;
export const checkInInput = z.object({
todos: z.array(todoInputSchema).min(1),
date: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(), // Default: heute
});
export type CheckInInput = z.infer<typeof checkInInput>;
export const submitReflectionInput = z.object({
dayEntryId: z.string().uuid(),
todos: z
.array(
z.object({
id: z.string().uuid(),
status: todoStatusSchema,
dropReason: z.string().max(200).optional(),
}),
)
.default([]),
reflectionText: z.string().max(1000).default(''),
ampel: ampelSchema,
trend: trendSchema.nullable().optional(),
blocker: z
.object({
title: z.string().min(1).max(200),
description: z.string().max(1000).optional(),
})
.optional(),
url: z.string().url().optional(),
});
export type SubmitReflectionInput = z.infer<typeof submitReflectionInput>;
export const todoMutationInput = z.object({
dayEntryId: z.string().uuid(),
todos: z.array(todoInputSchema).min(0),
});
export type TodoMutationInput = z.infer<typeof todoMutationInput>;
export const markAbsentInput = z.object({
from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
reason: z.enum(['urlaub', 'krank', 'sonstiges']),
});
export type MarkAbsentInput = z.infer<typeof markAbsentInput>;
+19
View File
@@ -0,0 +1,19 @@
import { z } from 'zod';
export const dealStageSchema = z.enum(['lead', 'erstgespraech', 'demo', 'angebot', 'abschluss']);
export const DEAL_STAGES = dealStageSchema.options;
export const createDealInput = z.object({
name: z.string().min(1).max(200),
ownerUserId: z.string().uuid().optional(),
valueEur: z.number().int().min(0),
probability: z.number().int().min(0).max(100).default(50),
stage: dealStageSchema.default('lead'),
});
export type CreateDealInput = z.infer<typeof createDealInput>;
export const updateDealStageInput = z.object({
dealId: z.string().uuid(),
stage: dealStageSchema,
});
export type UpdateDealStageInput = z.infer<typeof updateDealStageInput>;
+45
View File
@@ -0,0 +1,45 @@
import { z } from 'zod';
export const createProjectInput = z.object({
areaId: z.string().uuid(),
name: z.string().min(1).max(200),
kind: z.enum(['kundenprojekt', 'internes_projekt']),
verantwortlicheUserId: z.string().uuid().optional(),
arbeitsmodus: z.enum(['cycle', 'kanban']).optional(),
outlineSteckbriefUrl: z.string().url(),
planeProjectUrl: z.string().url(),
risikolisteUrl: z.string().url().optional(),
dodText: z.string().max(2000).optional(),
start: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(),
deadline: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(),
milestones: z
.array(
z.object({
title: z.string().min(1).max(200),
due: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(),
}),
)
.default([]),
});
export type CreateProjectInput = z.infer<typeof createProjectInput>;
export const archiveProjectInput = z.object({
projectId: z.string().uuid(),
lessonsLearnedUrl: z.string().url(),
});
export type ArchiveProjectInput = z.infer<typeof archiveProjectInput>;
export const setMilestoneStatusInput = z.object({
milestoneId: z.string().uuid(),
status: z.enum(['open', 'done', 'overdue']),
});
export type SetMilestoneStatusInput = z.infer<typeof setMilestoneStatusInput>;
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"noEmit": true
},
"include": ["src"]
}