Files

9.9 KiB
Raw Permalink Blame History

phase, plan, subsystem, tags, requires, provides, affects, tech-stack, key-files, key-decisions, patterns-established, requirements-completed, coverage, duration, completed, status
phase plan subsystem tags requires provides affects tech-stack key-files key-decisions patterns-established requirements-completed coverage duration completed status
12-tender-notifications 01 api
prisma
nestjs
tender-radar
notifications
matching
phase provides
11-tender-saved-searches TenderSavedSearch (filters Json, userId/tenantId scoping) + buildTenderWhere(dto) filter compiler
phase provides
10-tender-radar-ingestion TenderIngestionService.pollDueSources poll-tick + Tender catalog (dedupKey upsert)
TenderMatch model — matched-vs-notified state (single nullable notifiedAt gate)
TenderNotificationPref model — per-user digest interval preference
TenderSavedSearch.instantAlert — per-profile instant alert flag
TenderMatchingService.matchDelta(newTenderIds) — delta-only matching engine
pollDueSources now collects genuinely-new tender IDs and triggers matchDelta
12-02-tender-digest-send
12-03-tender-instant-alerts
12-04-tender-notification-settings-ui
added patterns
Delta-only matching: matchDelta only ever sees id IN newTenderIds — no historical rescan, structural backfill-flood prevention (D-07)
Idempotent match upsert on @@unique([tenderId,savedSearchId]) with update:{} preserves notifiedAt on re-match (D-06)
Indexed dedupKey pre-check (findUnique before upsert) to detect genuinely-new rows, since Prisma upsert doesn't report create-vs-update
created modified
apps/api/prisma/migrations/20260722100000_add_tender_notifications/migration.sql
apps/api/src/tenders/tender-matching.service.ts
apps/api/src/tenders/tender-matching.service.spec.ts
apps/api/prisma/schema.prisma
apps/api/src/tenders/tender-ingestion.service.ts
apps/api/src/tenders/tender-ingestion.service.spec.ts
apps/api/src/tenders/tenders.module.ts
One notifiedAt field (not two per-channel fields) — the eligibility gate is a single WHERE notifiedAt IS NULL predicate (D-06)
Delta-only matching instead of a backfill/suppression table — a new profile collects zero matches against the existing ~2188-row catalog by construction (D-07)
matchDelta wraps each profile's matching in its own try/catch — one broken profile's filters JSON never aborts matching for the rest (matches pollDueSources' existing catch-and-log convention)
matchDelta reuses buildTenderWhere(profile.filters as TenderQueryDto) — no new filter logic duplicated
NOTIFY-03
id description requirement verification human_judgment
D1 TenderMatch/TenderNotificationPref schema + TenderSavedSearch.instantAlert; migration applied to local dev DB NOTIFY-03
kind ref status
integration npx prisma validate && npx prisma generate (manual) + psql to_regclass check pass
false
id description requirement verification human_judgment
D2 TenderMatchingService.matchDelta — delta-only matching, match creation, idempotent upsert preserving notifiedAt, empty-delta no-op NOTIFY-03
kind ref status
unit apps/api/src/tenders/tender-matching.service.spec.ts (6 tests) pass
false
id description requirement verification human_judgment
D3 pollDueSources collects genuinely-new tender IDs (indexed dedupKey pre-check) and calls matchDelta only with those; TenderMatchingService registered in TendersModule NOTIFY-03
kind ref status
unit apps/api/src/tenders/tender-ingestion.service.spec.ts (9 tests, incl. 2 new delta-only tests) pass
false
35min 2026-07-22 complete

Phase 12 Plan 01: Schema + Delta-Only Matching Engine Summary

TenderMatch/TenderNotificationPref schema (single notifiedAt eligibility gate) plus a delta-only TenderMatchingService wired into pollDueSources — structurally prevents any backfill-flood on new saved-search profiles.

Performance

  • Duration: ~35 min
  • Completed: 2026-07-22T09:06:00Z
  • Tasks: 3 completed (Task 2 followed RED→GREEN TDD)
  • Files modified: 7 (3 created, 4 modified)

Accomplishments

  • Added TenderMatch (one row per tender × savedSearch pair, @@unique([tenderId, savedSearchId]), single nullable notifiedAt as the matched-vs-notified invariant gate) and TenderNotificationPref (per-user digest interval, default daily) models; added TenderSavedSearch.instantAlert Boolean @default(false).
  • Hand-written migration 20260722100000_add_tender_notifications applied to the local dev DB (tessera database, container tessera-ctl-db-1) and recorded in _prisma_migrations; prisma generate run so the client knows the new models.
  • Implemented TenderMatchingService.matchDelta(newTenderIds): for every active TenderSavedSearch, reuses buildTenderWhere(profile.filters) AND-ed with { id: { in: newTenderIds } }, and upserts TenderMatch idempotently (update: {} preserves any already-set notifiedAt).
  • Extended TenderIngestionService.pollDueSources: an indexed dedupKey pre-check now distinguishes genuinely-new tender rows from changed/re-seen ones; only the new IDs are handed to matchDelta at the end of the tick (delta-only boundary, D-07 — no historical rescan, no backfill/suppression table).
  • Registered TenderMatchingService as a provider in TendersModule and injected it into TenderIngestionService.

Task Commits

Each task was committed atomically:

  1. Task 1: Schema models + hand-written migration + local DB apply - 6c3e110 (feat)
  2. Task 2: TenderMatchingService.matchDelta (TDD) - 57d22a0 (test, RED) → 92d4c96 (feat, GREEN)
  3. Task 3: Wire matchDelta into pollDueSources + provider registration - b45047f (feat)

Plan metadata: commit pending (this SUMMARY + STATE/ROADMAP update)

Files Created/Modified

  • apps/api/prisma/schema.prisma - Added TenderMatch, TenderNotificationPref models; TenderSavedSearch.instantAlert + matches relation; Tender.matches relation
  • apps/api/prisma/migrations/20260722100000_add_tender_notifications/migration.sql - Hand-written migration, applied to local dev DB
  • apps/api/src/tenders/tender-matching.service.ts - matchDelta(newTenderIds): delta-only matching engine
  • apps/api/src/tenders/tender-matching.service.spec.ts - 6 unit tests (delta-only, match creation, idempotency, empty-delta)
  • apps/api/src/tenders/tender-ingestion.service.ts - Collects genuinely-new tender IDs; calls matchDelta at tick end
  • apps/api/src/tenders/tender-ingestion.service.spec.ts - 2 new tests asserting delta-only invocation of matchDelta
  • apps/api/src/tenders/tenders.module.ts - TenderMatchingService registered as provider

Decisions Made

  • Single notifiedAt field (not two per-channel timestamps) — matches RESEARCH.md's explicit recommendation; simpler invariant, no risk of implying double-send is allowed per channel.
  • Delta-only matching (no backfill/suppression table) as the structural mechanism for D-07 — verified: matchDelta([]) performs zero DB access, and a large simulated pre-existing catalog (2188 IDs) that would all match a profile's filters still yields zero matches when none of those IDs are passed as newTenderIds.
  • Added per-profile try/catch inside matchDelta (Rule 2 — matches the established pollDueSources catch-and-log convention; not explicitly specified in the plan's action text, but the plan's own <done> criteria and RESEARCH.md's "Don't Hand-Roll"/pitfalls make single-profile robustness an implicit correctness requirement, since a malformed filters JSON on one profile must not silently prevent matching for all other profiles in the same tick).

Deviations from Plan

Auto-fixed Issues

1. [Rule 1 - Bug] Plan's verify command referenced the wrong local DB name

  • Found during: Task 1 verification
  • Issue: The plan's <verify> block and 12-VALIDATION.md reference docker compose exec ... -d tessera_dev, but docker-compose.yml actually provisions POSTGRES_DB: tessera (confirmed via docker exec tessera-ctl-db-1 psql -U tessera -d tessera -c "\dt", which lists all existing Tender/TenderSavedSearch/TenderTriage tables). tessera_dev is the DB password, not the DB name.
  • Fix: Applied the migration and ran all verification (to_regclass, \d, prisma validate/generate) against -d tessera instead. No schema/code change — purely a corrected command target.
  • Verification: SELECT to_regclass('public."TenderMatch"'), to_regclass('public."TenderNotificationPref"') returned both table OIDs (non-null); \d "TenderSavedSearch" shows instantAlert boolean not null default false.
  • Committed in: 6c3e110 (Task 1 commit)

Total deviations: 1 auto-fixed (1 bug — wrong DB name in verify command, not a code defect) Impact on plan: No scope creep; purely a corrected verification target. All three tasks executed exactly as specified otherwise.

Issues Encountered

None beyond the DB-name correction above. gen_random_uuid() was available on the DB for the manual _prisma_migrations bookkeeping insert (Postgres 16 built-in via pgcrypto/native function).

User Setup Required

None - no external service configuration required. Migration was applied directly to the local dev DB per the environment constraints (no Docker rebuild, no test/prod server touched).

Next Phase Readiness

  • TenderMatch rows are now created (with notifiedAt=NULL) for every genuinely-new tender that matches an active saved search — ready for Plan 12-02 (digest send) and 12-03 (instant alerts) to consume via WHERE notifiedAt IS NULL.
  • TenderNotificationPref and TenderSavedSearch.instantAlert are in place for Plan 12-04's settings UI.
  • No blockers. pnpm --filter api test (full suite) is green: 172/172 tests across 15 files. npx tsc --noEmit clean.

Phase: 12-tender-notifications Completed: 2026-07-22

Self-Check: PASSED

All created files verified present on disk; all 4 task commit hashes (6c3e110, 57d22a0, 92d4c96, b45047f) verified present in git log.