feat(ST-1001): add LoginPairing model + pg_notify trigger via migration

Schema (prisma/schema.prisma):
- model LoginPairing met id (cuid), secret_hash + desktop_token_hash (beide NOT
  NULL — scheiden mobiel- en desktop-bewijs), status (pending|approved|consumed
  |cancelled), optionele user_id met onDelete: SetNull, desktop_ua VarChar(255),
  desktop_ip VarChar(45) voor IPv6, created_at + expires_at + approved_at +
  consumed_at, indexes op (expires_at) en (status, expires_at)
- back-relation login_pairings LoginPairing[] op User

Migratie (20260427200734_add_login_pairing):
- Prisma-gegenereerde DDL voor login_pairings + indexes + FK
- Toegevoegde notify_pairing_change() functie + login_pairings_notify trigger
  op AFTER INSERT/UPDATE; emit pg_notify('scrum4me_pairing', payload) met
  { op: 'I'|'U', pairing_id, status }
- DELETE niet ondersteund — pairings gaan naar consumed/cancelled, niet weg
- Channel naam analoog aan scrum4me_changes uit ST-801

Verification: Node pg-client roundtrip-test via DATABASE_URL toonde notifies bij
INSERT (op=I) en UPDATE (op=U) met correcte payload-shape.

Bouwt voort op M8 LISTEN/NOTIFY-infra. SSE-route /api/auth/pair/stream/[id] in
ST-1004 abonneert hierop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-04-27 22:10:51 +02:00
parent f0203fe314
commit 075cf28a5e
2 changed files with 87 additions and 0 deletions

View file

@ -0,0 +1,67 @@
-- CreateTable
CREATE TABLE "login_pairings" (
"id" TEXT NOT NULL,
"secret_hash" TEXT NOT NULL,
"desktop_token_hash" TEXT NOT NULL,
"status" TEXT NOT NULL,
"user_id" TEXT,
"desktop_ua" VARCHAR(255),
"desktop_ip" VARCHAR(45),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expires_at" TIMESTAMP(3) NOT NULL,
"approved_at" TIMESTAMP(3),
"consumed_at" TIMESTAMP(3),
CONSTRAINT "login_pairings_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "login_pairings_expires_at_idx" ON "login_pairings"("expires_at");
-- CreateIndex
CREATE INDEX "login_pairings_status_expires_at_idx" ON "login_pairings"("status", "expires_at");
-- AddForeignKey
ALTER TABLE "login_pairings" ADD CONSTRAINT "login_pairings_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- ST-1001: Postgres LISTEN/NOTIFY voor QR-pairing flow.
--
-- AFTER INSERT/UPDATE-trigger op login_pairings emit een JSON-payload op het
-- `scrum4me_pairing`-kanaal. De SSE-route /api/auth/pair/stream/[pairingId]
-- (ST-1004) abonneert op dit kanaal en filtert per pairing_id.
--
-- DELETE wordt niet ondersteund — pairings gaan naar status='consumed' of
-- 'cancelled', niet weg. Een eventuele cleanup-job die rijen wel deleten zou
-- kan dat zonder dit kanaal te bereiken.
--
-- Payload shape:
-- { op: 'I' | 'U',
-- pairing_id: text,
-- status: text }
--
-- Channel-name is hardcoded analoog aan `scrum4me_changes` uit ST-801. Bij
-- wijziging deze migratie én app/api/auth/pair/stream/[pairingId]/route.ts
-- bijwerken.
CREATE OR REPLACE FUNCTION notify_pairing_change() RETURNS trigger AS $$
DECLARE
payload jsonb;
BEGIN
payload := jsonb_build_object(
'op', CASE TG_OP
WHEN 'INSERT' THEN 'I'
WHEN 'UPDATE' THEN 'U'
END,
'pairing_id', NEW.id,
'status', NEW.status
);
PERFORM pg_notify('scrum4me_pairing', payload::text);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS login_pairings_notify ON login_pairings;
CREATE TRIGGER login_pairings_notify
AFTER INSERT OR UPDATE ON login_pairings
FOR EACH ROW EXECUTE FUNCTION notify_pairing_change();

View file

@ -65,6 +65,7 @@ model User {
todos Todo[]
product_members ProductMember[]
assigned_stories Story[] @relation("StoryAssignee")
login_pairings LoginPairing[]
@@index([active_product_id])
@@map("users")
@ -247,3 +248,22 @@ model Todo {
@@index([user_id, product_id])
@@map("todos")
}
model LoginPairing {
id String @id @default(cuid())
secret_hash String
desktop_token_hash String
status String
user_id String?
user User? @relation(fields: [user_id], references: [id], onDelete: SetNull)
desktop_ua String? @db.VarChar(255)
desktop_ip String? @db.VarChar(45)
created_at DateTime @default(now())
expires_at DateTime
approved_at DateTime?
consumed_at DateTime?
@@index([expires_at])
@@index([status, expires_at])
@@map("login_pairings")
}