diff --git a/apps/api/prisma/migrations/20260722100000_add_tender_notifications/migration.sql b/apps/api/prisma/migrations/20260722100000_add_tender_notifications/migration.sql new file mode 100644 index 0000000..1cca167 --- /dev/null +++ b/apps/api/prisma/migrations/20260722100000_add_tender_notifications/migration.sql @@ -0,0 +1,59 @@ +-- AlterTable +-- NOTIFY-02 (D-04): Sofort-Alert pro Profil, Default AUS. +ALTER TABLE "TenderSavedSearch" ADD COLUMN IF NOT EXISTS "instantAlert" BOOLEAN NOT NULL DEFAULT false; + +-- CreateTable +-- NOTIFY-03 Kern-Invariante — der "getroffen"-Datensatz (D-06). Ein Row je +-- (tender x savedSearch)-Paar. EIN einziges "notifiedAt" ist das +-- Eligibility-Gate: NULL = noch nicht benachrichtigt, gesetzt (instant +-- ODER digest) = das Paar wird NIE wieder benachrichtigt. Match-Erzeugung +-- ist ein idempotenter Upsert auf @@unique(tenderId, savedSearchId) mit +-- update:{} — ein Re-Match bewahrt ein bereits gesetztes notifiedAt. +-- Scoping wie TenderTriage/TenderSavedSearch: userId ist das Scoping-Feld +-- fuer Reads (where:{userId}, IDOR-Schutz), tenantId zusaetzlich fuer die +-- Mandanten-SMTP-Auflösung im Digest/Instant-Versand — KEIN forTenant()/RLS. +CREATE TABLE IF NOT EXISTS "TenderMatch" ( + "id" TEXT NOT NULL, + "tenderId" TEXT NOT NULL, + "savedSearchId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "matchedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "notifiedAt" TIMESTAMP(3), + "notifiedChannel" TEXT, + + CONSTRAINT "TenderMatch_pkey" PRIMARY KEY ("id"), + CONSTRAINT "TenderMatch_tenderId_fkey" FOREIGN KEY ("tenderId") REFERENCES "Tender"("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "TenderMatch_savedSearchId_fkey" FOREIGN KEY ("savedSearchId") REFERENCES "TenderSavedSearch"("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +-- NOTIFY-01 (D-03) — Digest-Intervall ist eine per-USER Einstellung, nicht +-- pro Profil. Default 'daily' (D-01). +CREATE TABLE IF NOT EXISTS "TenderNotificationPref" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "digestInterval" TEXT NOT NULL DEFAULT 'daily', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "TenderNotificationPref_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +-- Upsert-Target — ein Match je (tender x savedSearch)-Paar, idempotent. +CREATE UNIQUE INDEX IF NOT EXISTS "TenderMatch_tenderId_savedSearchId_key" ON "TenderMatch"("tenderId", "savedSearchId"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "TenderMatch_userId_idx" ON "TenderMatch"("userId"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "TenderMatch_notifiedAt_idx" ON "TenderMatch"("notifiedAt"); + +-- CreateIndex +-- Ein Pref-Row je Nutzer. +CREATE UNIQUE INDEX IF NOT EXISTS "TenderNotificationPref_userId_key" ON "TenderNotificationPref"("userId"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "TenderNotificationPref_userId_idx" ON "TenderNotificationPref"("userId"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index c4ae466..3a79141 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -290,7 +290,8 @@ model Tender { @@index([bundesland]) // FILTER-02: post-backfill Bundesland-Filter-Performance @@index([cpvDivisions], type: Gin) // FILTER-03: post-backfill CPV-Divisions-Filter-Performance (hasSome) // Deliberately NO tenant column and NO tenant index — this is global data (D-03) - triage TenderTriage[] + triage TenderTriage[] + matches TenderMatch[] } // UI-03/04 — per-user Triage-Zustand pro Tender (gelesen/ungelesen, Favorit). @@ -324,18 +325,67 @@ model TenderTriage { // (deckungsgleich mit den URL-searchParams, Plan 11-06), nicht Tender-Ids, // daher unkritisch bei der 90-Tage-Retention. model TenderSavedSearch { - id String @id @default(uuid()) - userId String - tenantId String - name String - filters Json // serialisierte Filterkombination (q, plz, bundesland, region, cpv, deadlineFrom/To, openOnly, valueMin/Max, includeNullValue, sort, favOnly) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(uuid()) + userId String + tenantId String + name String + filters Json // serialisierte Filterkombination (q, plz, bundesland, region, cpv, deadlineFrom/To, openOnly, valueMin/Max, includeNullValue, sort, favOnly) + instantAlert Boolean @default(false) // NOTIFY-02/D-04 — Sofort-Alert pro Profil, Default AUS + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + matches TenderMatch[] // Gegenrelation (NOTIFY-03) @@unique([userId, name]) // keine zwei Profile gleichen Namens pro Nutzer @@index([userId]) } +// NOTIFY-03 Kern-Invariante — der "getroffen"-Datensatz (D-06), ein Row je +// (tender x savedSearch)-Paar. Ein einziges `notifiedAt` ist das +// Eligibility-Gate: NULL = noch nicht benachrichtigt, gesetzt (egal ob +// durch 'instant' oder 'digest') = das Paar wird NIE wieder benachrichtigt +// (D-06 — kein Doppelversand, weder Digest+Instant noch zweimal im selben +// Kanal). Match-Erzeugung ist ein idempotenter Upsert auf +// @@unique([tenderId, savedSearchId]) mit `update: {}` — ein Re-Match +// bewahrt ein bereits gesetztes notifiedAt strukturell (T-12-04). +// +// Scoping-Muster wie TenderTriage/TenderSavedSearch (Pitfall 4): userId +// wird aus dem Profil denormalisiert mitgeführt und ist das Scoping-Feld +// für Reads (where:{userId}, IDOR-Schutz), tenantId zusätzlich für die +// spätere Mandanten-SMTP-Auflösung im Digest/Instant-Versand — KEIN +// forTenant()/RLS. +model TenderMatch { + id String @id @default(uuid()) + tenderId String + savedSearchId String + userId String // denormalisiert vom Profil — Scoping-Feld für Reads + tenantId String // denormalisiert vom Profil — SMTP-Auflösung (D-08) + matchedAt DateTime @default(now()) + notifiedAt DateTime? // NULL = noch nicht benachrichtigt (das Eligibility-Gate, D-06) + notifiedChannel String? // 'digest' | 'instant' — nur Audit, NICHT Teil der Invariante + tender Tender @relation(fields: [tenderId], references: [id], onDelete: Cascade) + savedSearch TenderSavedSearch @relation(fields: [savedSearchId], references: [id], onDelete: Cascade) + + @@unique([tenderId, savedSearchId]) // Upsert-Target — ein Match je Paar, idempotent + @@index([userId]) // Digest-/Instant-Query: where userId, notifiedAt null + @@index([notifiedAt]) +} + +// NOTIFY-01/D-03 — Digest-Intervall ist eine per-USER Einstellung (nicht +// pro Profil): ein Digest deckt alle Suchprofile eines Nutzers ab. Default +// 'daily' (D-01). Scoping-Muster wie TenderTriage (userId, kein +// forTenant()/RLS); tenantId zusätzlich für SMTP-Auflösung im Digest-Cron. +model TenderNotificationPref { + id String @id @default(uuid()) + userId String @unique // ein Pref-Row je Nutzer + tenantId String + digestInterval String @default("daily") // 'daily' | 'weekly' | 'off' (D-01) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) +} + // Singleton-per-source admin poll config (INGEST-06 foundation). model TenderSourcePollConfig { id String @id @default(uuid())