From 79367dda7bf10e1a0c95786d861bcb08c6674a99 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Tue, 28 Apr 2026 00:50:26 +0200 Subject: [PATCH] feat(ST-1101): add ClaudeQuestion model + notify_question_change trigger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema (prisma/schema.prisma): - Nieuw model ClaudeQuestion: id (cuid), story_id (FK Cascade), task_id? (FK SetNull), product_id (FK Cascade — gedenormaliseerd uit story.product_id voor SSE-filter zonder join), asked_by (FK Restrict — Claude-token-houder), question (Text), options (Json? — string[] voor multi-choice), status ('open'|'answered'|'cancelled'|'expired'), answer (Text?), answered_by (FK SetNull), answered_at?, created_at, expires_at - Indexes: (story_id, status), (product_id, status), (status, expires_at) - Back-relations: User.asked_questions (ClaudeQuestionAsker), User.answered_questions (ClaudeQuestionAnswerer), Story.claude_questions, Task.claude_questions, Product.claude_questions Migratie (20260427224849_add_claude_questions): - Prisma-gegenereerde DDL voor claude_questions + indexes + 5 FK's - Toegevoegde notify_question_change() functie + claude_questions_notify trigger op AFTER INSERT/UPDATE - Emit op BESTAANDE scrum4me_changes-channel met entity:'question' (i.t.t. M10 dat eigen scrum4me_pairing-channel kreeg) — solo-route in ST-1104 moet entity='question' wegfilteren om regressie op solo-board te voorkomen - Trigger leest story.assignee_id voor "wacht op jou"-emphase in payload - DELETE niet ondersteund — questions gaan naar answered/cancelled/expired Verification: Node pg-client roundtrip via DATABASE_URL toonde correcte payloads bij INSERT (op=I, status=open) en UPDATE (op=U, status=answered) met alle FK-IDs en assignee_id correct uit story-join. Volgende stap M11: ST-1102 — vier MCP-tools in scrum4me-mcp-repo (ask_user_question, get_question_answer, list_open_questions, cancel_question). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../migration.sql | 99 +++++++++++++++++++ prisma/schema.prisma | 31 ++++++ 2 files changed, 130 insertions(+) create mode 100644 prisma/migrations/20260427224849_add_claude_questions/migration.sql diff --git a/prisma/migrations/20260427224849_add_claude_questions/migration.sql b/prisma/migrations/20260427224849_add_claude_questions/migration.sql new file mode 100644 index 0000000..9b83487 --- /dev/null +++ b/prisma/migrations/20260427224849_add_claude_questions/migration.sql @@ -0,0 +1,99 @@ +-- CreateTable +CREATE TABLE "claude_questions" ( + "id" TEXT NOT NULL, + "story_id" TEXT NOT NULL, + "task_id" TEXT, + "product_id" TEXT NOT NULL, + "asked_by" TEXT NOT NULL, + "question" TEXT NOT NULL, + "options" JSONB, + "status" TEXT NOT NULL, + "answer" TEXT, + "answered_by" TEXT, + "answered_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "claude_questions_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "claude_questions_story_id_status_idx" ON "claude_questions"("story_id", "status"); + +-- CreateIndex +CREATE INDEX "claude_questions_product_id_status_idx" ON "claude_questions"("product_id", "status"); + +-- CreateIndex +CREATE INDEX "claude_questions_status_expires_at_idx" ON "claude_questions"("status", "expires_at"); + +-- AddForeignKey +ALTER TABLE "claude_questions" ADD CONSTRAINT "claude_questions_story_id_fkey" FOREIGN KEY ("story_id") REFERENCES "stories"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "claude_questions" ADD CONSTRAINT "claude_questions_task_id_fkey" FOREIGN KEY ("task_id") REFERENCES "tasks"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "claude_questions" ADD CONSTRAINT "claude_questions_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "claude_questions" ADD CONSTRAINT "claude_questions_asked_by_fkey" FOREIGN KEY ("asked_by") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "claude_questions" ADD CONSTRAINT "claude_questions_answered_by_fkey" FOREIGN KEY ("answered_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- ST-1101: Postgres LISTEN/NOTIFY voor het Claude vraag-antwoord-kanaal (M11). +-- +-- AFTER INSERT/UPDATE-trigger op claude_questions emit een JSON-payload op het +-- BESTAANDE `scrum4me_changes`-kanaal (zelfde als ST-801) met `entity: 'question'`. +-- +-- De solo-realtime SSE-route `/api/realtime/solo` MOET in ST-1104 een +-- `if (payload.entity === 'question') return false`-filter krijgen — anders +-- ontvangen solo-clients ongewenst question-events. +-- +-- De nieuwe user-scoped SSE-route `/api/realtime/notifications` (ST-1104) +-- abonneert zich op hetzelfde kanaal en filtert op `entity === 'question'` +-- + product-toegang van de gebruiker. +-- +-- DELETE wordt niet ondersteund — questions gaan naar status='answered', +-- 'cancelled' of 'expired', niet weg. +-- +-- Payload shape: +-- { op: 'I' | 'U', +-- entity: 'question', +-- id: text, +-- product_id: text, +-- story_id: text, +-- task_id: text|null, +-- assignee_id: text|null, // story.assignee_id, voor "wacht op jou"-emphase +-- status: 'open'|'answered'|'cancelled'|'expired' } + +CREATE OR REPLACE FUNCTION notify_question_change() RETURNS trigger AS $$ +DECLARE + story_row record; + payload jsonb; +BEGIN + SELECT assignee_id INTO story_row FROM stories WHERE id = NEW.story_id; + + payload := jsonb_build_object( + 'op', CASE TG_OP + WHEN 'INSERT' THEN 'I' + WHEN 'UPDATE' THEN 'U' + END, + 'entity', 'question', + 'id', NEW.id, + 'product_id', NEW.product_id, + 'story_id', NEW.story_id, + 'task_id', NEW.task_id, + 'assignee_id', story_row.assignee_id, + 'status', NEW.status + ); + + PERFORM pg_notify('scrum4me_changes', payload::text); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS claude_questions_notify ON claude_questions; +CREATE TRIGGER claude_questions_notify + AFTER INSERT OR UPDATE ON claude_questions + FOR EACH ROW EXECUTE FUNCTION notify_question_change(); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 920907b..466d538 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -66,6 +66,8 @@ model User { product_members ProductMember[] assigned_stories Story[] @relation("StoryAssignee") login_pairings LoginPairing[] + asked_questions ClaudeQuestion[] @relation("ClaudeQuestionAsker") + answered_questions ClaudeQuestion[] @relation("ClaudeQuestionAnswerer") @@index([active_product_id]) @@map("users") @@ -112,6 +114,7 @@ model Product { todos Todo[] members ProductMember[] active_for_users User[] @relation("UserActiveProduct") + claude_questions ClaudeQuestion[] @@unique([user_id, name]) @@unique([user_id, code]) @@ -158,6 +161,7 @@ model Story { updated_at DateTime @updatedAt logs StoryLog[] tasks Task[] + claude_questions ClaudeQuestion[] @@unique([product_id, code]) @@index([pbi_id, priority, sort_order]) @@ -212,6 +216,7 @@ model Task { status TaskStatus @default(TO_DO) created_at DateTime @default(now()) updated_at DateTime @updatedAt + claude_questions ClaudeQuestion[] @@index([story_id, priority, sort_order]) @@index([sprint_id, status]) @@ -267,3 +272,29 @@ model LoginPairing { @@index([status, expires_at]) @@map("login_pairings") } + +model ClaudeQuestion { + id String @id @default(cuid()) + story Story @relation(fields: [story_id], references: [id], onDelete: Cascade) + story_id String + task Task? @relation(fields: [task_id], references: [id], onDelete: SetNull) + task_id String? + product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) + product_id String // gedenormaliseerd uit story.product_id voor SSE-filter + asker User @relation("ClaudeQuestionAsker", fields: [asked_by], references: [id]) + asked_by String // user_id van token-houder (= Claude-token) + question String @db.Text + options Json? // string[] voor multi-choice; null voor free-text + status String // 'open' | 'answered' | 'cancelled' | 'expired' + answer String? @db.Text + answerer User? @relation("ClaudeQuestionAnswerer", fields: [answered_by], references: [id]) + answered_by String? + answered_at DateTime? + created_at DateTime @default(now()) + expires_at DateTime // ingesteld door MCP-tool, default now() + 24h + + @@index([story_id, status]) + @@index([product_id, status]) + @@index([status, expires_at]) + @@map("claude_questions") +}