feat(12-01): add TenderMatch/TenderNotificationPref schema + apply migration
- TenderMatch: one row per (tenderId, savedSearchId) pair, single nullable notifiedAt as the matched-vs-notified eligibility gate (D-06) - TenderNotificationPref: per-user digest interval (daily/weekly/off, D-01/D-03) - TenderSavedSearch.instantAlert: per-profile instant alert flag, default off (D-04) - Migration 20260722100000_add_tender_notifications applied to local dev DB (docker exec psql), recorded in _prisma_migrations, prisma generate run Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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");
|
||||||
@@ -291,6 +291,7 @@ model Tender {
|
|||||||
@@index([cpvDivisions], type: Gin) // FILTER-03: post-backfill CPV-Divisions-Filter-Performance (hasSome)
|
@@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)
|
// 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).
|
// UI-03/04 — per-user Triage-Zustand pro Tender (gelesen/ungelesen, Favorit).
|
||||||
@@ -329,13 +330,62 @@ model TenderSavedSearch {
|
|||||||
tenantId String
|
tenantId String
|
||||||
name String
|
name String
|
||||||
filters Json // serialisierte Filterkombination (q, plz, bundesland, region, cpv, deadlineFrom/To, openOnly, valueMin/Max, includeNullValue, sort, favOnly)
|
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())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
matches TenderMatch[] // Gegenrelation (NOTIFY-03)
|
||||||
|
|
||||||
@@unique([userId, name]) // keine zwei Profile gleichen Namens pro Nutzer
|
@@unique([userId, name]) // keine zwei Profile gleichen Namens pro Nutzer
|
||||||
@@index([userId])
|
@@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).
|
// Singleton-per-source admin poll config (INGEST-06 foundation).
|
||||||
model TenderSourcePollConfig {
|
model TenderSourcePollConfig {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
|
|||||||
Reference in New Issue
Block a user