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:
2026-07-22 09:02:18 +02:00
parent 37a4042e53
commit 6c3e110949
2 changed files with 117 additions and 8 deletions
+58 -8
View File
@@ -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())