Commit Graph

548 Commits

Author SHA1 Message Date
schalli c4d7b7ded6 docs(15-02): complete Gruppenverwaltung & Standardgruppen-Mitgliedschaft plan 2026-08-04 15:27:48 +02:00
schalli 33838dde39 feat(15-02): automatische Standardgruppen-Mitgliedschaft an genau einem Ort
- UserService.create ruft nach der Anlage GroupsService.addUserToDefaultGroup
  auf (D-11/D-12) — einziger Erzeugungspunkt für Benutzer, erbt LdapService
  ohne eigene Kopie der Regel
- try/catch mit Logger: gescheiterte Gruppenzuordnung bricht weder die
  Benutzeranlage noch einen LDAP-Sync-Lauf ab (T-15-14)
- UserModule importiert GroupsModule, keine Zirkularität
- 4 Tests in user.service.spec.ts; ldap.service.ts unverändert
2026-08-04 15:23:01 +02:00
schalli 69494d7549 feat(15-02): GroupsModule — CRUD für Gruppen, Mitgliedschaften und Löschauswirkung
- GroupsService: listForTenant/create/update/remove/getImpact/listMembers/addMembers/removeMember/addUserToDefaultGroup, jede Query tenantId-gescoped (T-15-02/T-15-12)
- isDefault:true läuft in einer Transaktion (updateMany+update), D-13
- getImpact liefert { memberCount, grantCount } für den Löschdialog (D-17)
- removeMember beschränkt sich auf source:MANUAL (D-19)
- GroupsController: 8 rollengeschützte Routen unter /groups
- 19 Tests in groups.service.spec.ts, hand-rolled In-Memory-Fake
2026-08-04 15:21:41 +02:00
schalli 79c83eae5f docs(15-01): complete Group/ModuleGrant foundation plan 2026-08-04 15:14:21 +02:00
schalli 4fd2f2e6a6 docs(15-01): append self-check result to SUMMARY 2026-08-04 15:13:47 +02:00
schalli 3b3950cfdb docs(15-01): add SUMMARY for Group/ModuleGrant foundation + access resolution tracer 2026-08-04 15:13:33 +02:00
schalli 92e8eaffa5 feat(15-01): RLS policies for Group/GroupMembership/ModuleGrant (T-15-11)
- Second, deliberately separate migration (pure hand-SQL, no Prisma-
  generated DDL): ENABLE/FORCE ROW LEVEL SECURITY plus a
  tenant_isolation_policy for each of the three new tables, following
  the pattern of 20260618112133_rls_policies (Auth-Kerntabellen)
  rather than the RLS-exempt Tender* app-layer tables
- Group/ModuleGrant compare tenantId directly against
  current_tenant_id(); GroupMembership has no own tenantId and follows
  the PasswordResetToken join pattern (groupId IN (SELECT id FROM
  Group WHERE tenantId = ...))
- migration-sql.spec.ts extended with a second describe block covering
  both migration files (6x ROW LEVEL SECURITY, 3x CREATE POLICY, the
  join vs. direct-comparison shape)
- Re-ran the Task-2 end-to-end proof after applying this migration:
  identical result (USER without grant 403 + empty list, USER with
  direct grant 200 + slug present, ADMIN 200) — the app's DB role
  (tessera) is a Postgres superuser with rolbypassrls=true, so it
  bypasses RLS as documented as an acceptable outcome by the plan;
  RLS remains the defense-in-depth net for any future non-superuser
  connection
2026-08-04 15:11:33 +02:00
schalli 9a4ba8a33c feat(15-01): ModuleAccessService as single source of truth for module access (D-01)
- ModuleAccessService.getAccessibleModuleIds(tenantId, userId, role):
  ADMIN/SUPER_ADMIN bypass (D-03) via one query, otherwise a single
  Promise.all of direct + group ModuleGrant lookups intersected against
  active TenantModuleActivation (D-02) — no N+1 over the user's groups
- findAccessibleModules() adds the name-asc sort for stable sidebar order
- ModuleGuard now resolves userId/role from request.user (JWT-sourced,
  never body/params) and calls getAccessibleModuleIds instead of the
  tenant-only isModuleActive check; caches the result on
  request.moduleAccessIds for same-request reuse (D-09, no cross-request
  caching)
- ModuleRegistryController.findActive delegates to
  ModuleAccessService.findAccessibleModules instead of
  findActiveForTenant, which stays untouched for Plan 15-03's
  tenant-wide marketplace catalog
- ModuleRegistryModule exports ModuleAccessService for Plan 15-03/15-05
- module-access.service.spec.ts / module.guard.spec.ts cover every case
  in the plan's <behavior> list with a hand-rolled Prisma mock
- End-to-end verified against the running local API: a USER without a
  grant gets 403 on a @UseModule-protected endpoint and an empty
  /modules/active list; the same USER with a direct grant gets 200 plus
  the slug in the list; an ADMIN without any grant also gets 200 (D-03)
2026-08-04 15:09:10 +02:00
schalli c5c704bae9 feat(15-01): Group/GroupMembership/ModuleGrant schema + D-06 backfill migration
- Group/GroupMembership/ModuleGrant models plus MembershipSource enum
  (D-05), placed under TenantModuleActivation with German block comment
- Hand-SQL appended to the generated migration: partial unique index for
  one default group per tenant (D-13), CHECK num_nonnulls xor-constraint
  plus two partial unique indexes for ModuleGrant (D-04), and the D-06
  backfill (Group -> GroupMembership -> ModuleGrant, each INSERT guarded
  by WHERE NOT EXISTS for idempotent re-runs on `prisma migrate deploy`)
- apps/api/src/groups/migration-sql.spec.ts verifies the hand-SQL by
  reading migration.sql directly, no DB required
- Verified against the local DB: default-group count matches tenant
  count, membership/grant counts match existing users/active
  activations, and the XOR constraint rejects a group+user-less insert
2026-08-04 15:03:48 +02:00
schalli ac65149964 docs(15): create phase plan 2026-08-04 14:44:05 +02:00
schalli 8a4bb32847 docs(15): create phase plan — 8 plans, 4 waves, PERM-01..07
Tessera CI/CD / Lint & Type Check (push) Successful in 47s
Tessera CI/CD / Tests (push) Successful in 46s
Tessera CI/CD / Build & Publish Images (push) Successful in 6s
2026-08-04 14:39:46 +02:00
schalli ff22178847 docs(state): record phase 15 UI-SPEC session
Tessera CI/CD / Lint & Type Check (push) Successful in 45s
Tessera CI/CD / Tests (push) Successful in 47s
Tessera CI/CD / Build & Publish Images (push) Successful in 6s
2026-08-04 13:58:34 +02:00
schalli 4f78999238 docs(15): UI design contract
Tessera CI/CD / Lint & Type Check (push) Successful in 43s
Tessera CI/CD / Tests (push) Successful in 45s
Tessera CI/CD / Build & Publish Images (push) Successful in 7s
2026-08-04 13:58:33 +02:00
schalli 37ab7c537d docs(15): record owner-approved typography exception, add icon-only aria-labels
Tessera CI/CD / Lint & Type Check (push) Successful in 44s
Tessera CI/CD / Tests (push) Successful in 49s
Tessera CI/CD / Build & Publish Images (push) Successful in 6s
2026-08-04 13:54:44 +02:00
schalli 2f354cd59c docs(15): UI design contract
Tessera CI/CD / Lint & Type Check (push) Successful in 45s
Tessera CI/CD / Tests (push) Successful in 48s
Tessera CI/CD / Build & Publish Images (push) Successful in 7s
2026-08-04 13:39:00 +02:00
schalli 960acb55c5 docs(phase-15): add validation strategy
Tessera CI/CD / Lint & Type Check (push) Successful in 48s
Tessera CI/CD / Tests (push) Successful in 51s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m55s
2026-08-04 13:32:14 +02:00
schalli 32c4ec3fc8 docs(15): research phase domain 2026-08-04 13:30:53 +02:00
schalli d4ae20b300 docs(15): add PERM-01..07 requirements and widget success criterion 2026-08-04 11:59:50 +02:00
schalli ce96e5a5e7 docs(state): record phase 15 context session 2026-08-04 10:52:41 +02:00
schalli 6859c7504e docs(15): capture phase context 2026-08-04 10:52:37 +02:00
schalli 7e1b4a2964 docs(roadmap): add Phase 15 module permissions (groups & user grants)
Two-level module access: tenant activation stays a prerequisite, plus new
per-group and per-user grants. Records the four design decisions taken with
the user: Tessera-owned groups with optional AD binding, closed-by-default
access, ADMIN/SUPER_ADMIN bypass within their tenant, and access on/off only
(no permission levels inside modules). Starts milestone v1.2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 08:42:56 +02:00
schalli bd84a26824 docs(260729-d3k): add plan + verification (passed 6/6) for LDAP multi-base-DN
Tessera CI/CD / Lint & Type Check (push) Successful in 44s
Tessera CI/CD / Tests (push) Successful in 48s
Tessera CI/CD / Build & Publish Images (push) Successful in 1m40s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 09:43:06 +02:00
schalli 97465a6f25 docs(260729-d3k): complete LDAP multi-base-DN quick task 2026-07-29 09:40:29 +02:00
schalli 96be7e168b feat(260729-d3k): multi-line Base-DN textarea + reworked scope i18n
- Base-DN admin field is now a multi-line textarea (one DN per line),
  value stays a single newline-separated string, no schema change
- baseDnHint key added (de/en) explaining the Base-DN(s) sync scope
- groupFilter.description/emptyMeansAll reworded: group filter is an
  optional extra restriction; empty selection means all users under
  the base DN(s) are synced (drops the old "nothing is synced"
  framing)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 09:37:51 +02:00
schalli 5cbd530a87 feat(260729-d3k): multi-base LDAP sync scope + re-keyed no-op guard
- parseBaseDns() splits the newline-separated baseDn field into a list
- syncUsersForTenant no-op guard re-keyed on empty parsed base-DN list
  (was empty groupFilterDns) — the sole condition that skips search +
  the deactivation loop, preventing mass-deactivation on an
  unconfigured config
- collectSearchEntries/listGroups/searchUsers loop every base DN and
  merge/dedupe results by entry dn
- empty groupFilterDns is no longer a no-op: it now performs a normal
  multi-base search with no memberOf restriction
- groupFilterDns ou= entries stay additional search bases; group DNs
  become an optional memberOf constraint applied to every base search
- spec: replaced empty-groupFilterDns no-op test with empty-base-DN
  no-op test, added multi-base merge/dedup test

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 09:36:55 +02:00
schalli 5cdd48d864 docs(260728-lih): add plan + verification (passed 6/6) for LDAP selective sync
Tessera CI/CD / Lint & Type Check (push) Successful in 44s
Tessera CI/CD / Tests (push) Successful in 46s
Tessera CI/CD / Build & Publish Images (push) Successful in 1m44s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 15:47:22 +02:00
schalli fd9c1bee34 docs(260728-lih): complete LDAP sync selektiv und auto-sync default plan 2026-07-28 15:43:37 +02:00
schalli 63a07abb47 feat(260728-lih): default LDAP create-form syncIntervalMin to 0 + reword copy
- New-config create form now defaults syncIntervalMin to 0 (matches
  backend default, auto-sync off by default)
- de+en groupFilter.description + emptyMeansAll reworded: empty
  selection now says "nothing is synced" instead of "imports everyone
  under the base DN" (matches the backend semantic change)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 15:42:10 +02:00
schalli 57bc7f96b3 feat(260728-lih): make LDAP sync strictly selective (empty selection = no-op)
- collectSearchEntries() returns [] on empty/undefined groupFilterDns
  instead of scanning the whole baseDn subtree
- syncUsersForTenant() early-returns an empty successful result before
  any LDAP search or the deactivation loop when groupFilterDns is empty,
  so an empty selection can never mass-deactivate existing LDAP users
- Updated exclude-list tests to use a non-empty groupFilterDns; added a
  dedicated no-op test proving empty selection performs zero search/
  create/update/deactivate operations

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 15:41:33 +02:00
schalli c54e424c05 feat(260728-lih): default LDAP syncIntervalMin to 0 (auto-sync off)
- LdapConfig.syncIntervalMin default changed 60 -> 0
- New migration sets column DEFAULT only, no data rewrite
- isActive @default(true) left unchanged (gates LDAP login only)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 15:40:28 +02:00
schalli 7e5a6a6e01 docs(planning): add v1.1 milestone audit and 260723-lvg quick plan
Tessera CI/CD / Lint & Type Check (push) Successful in 47s
Tessera CI/CD / Tests (push) Successful in 45s
Tessera CI/CD / Build & Publish Images (push) Successful in 7s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 14:55:27 +02:00
schalli ae85b91468 fix(admin): refresh sidebar after module toggle on admin page
Tessera CI/CD / Lint & Type Check (push) Successful in 52s
Tessera CI/CD / Tests (push) Successful in 54s
Tessera CI/CD / Build & Publish Images (push) Successful in 1m25s
Activating/deactivating a module from the admin modules page updated only
the page's local state — the sidebar (which refetches its active-module
list on the shared marketplace-store sidebarRefreshKey signal) was never
bumped, so the module's nav link only appeared/disappeared after a manual
full page reload. The marketplace pages already call bumpSidebarRefresh()
after a toggle; mirror that here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 12:10:39 +02:00
schalli 51501aa7ee docs(260723-lvg): complete "Jetzt abrufen" quick task summary
Tessera CI/CD / Lint & Type Check (push) Successful in 1m13s
Tessera CI/CD / Tests (push) Successful in 1m5s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m52s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 15:55:23 +02:00
schalli bdaf154f66 feat(260723-lvg): add "Jetzt abrufen" manual poll button to tender radar
PollNowButton sits next to the settings gear in the tender-radar header,
mirrors the DKV spinner/disabled UX, and bumps a refreshKey on success to
refetch ResultsList without touching any filter. Failure surfaces an
i18n error (de/en parity). pollNow() client hits POST
/modules/tender-radar/poll-now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 15:54:43 +02:00
schalli 7502f97e85 feat(260723-lvg): add admin-gated POST /poll-now endpoint for tender radar
Manual "Jetzt abrufen" trigger delegates to
TenderIngestionService.pollDueSources() — the same fan-out tick the
scheduler cron runs. Gated to ADMIN/SUPER_ADMIN (T-lvg-01, DoS) and
declared before @Get(':id') per the established route-order convention.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 15:52:44 +02:00
schalli e1358d70fd fix(tenders): register poll cron in onApplicationBootstrap (fresh-DB bootstrap)
Tessera CI/CD / Lint & Type Check (push) Successful in 48s
Tessera CI/CD / Tests (push) Successful in 48s
Tessera CI/CD / Build & Publish Images (push) Successful in 26s
On a fresh database the DÖE poll cron was never registered: TenderScheduler
read the doe-opendata poll config in its onModuleInit, which raced ahead of
TendersModule.onModuleInit seeding that config. The scheduler saw the config
absent → skipped registering the single global cron that drives pollDueSources
(DÖE + RSS + email-alert) → the platform ingested NOTHING until a second restart.
Observed live on a fresh prod DB (0 tenders, 'doe-opendata config inactive —
cron job not registered', lastIngestedDay null despite isActive=true).

Move the scheduler to onApplicationBootstrap, which runs after every module's
onModuleInit, so the seed is guaranteed complete before the config is read.
Adds a regression test asserting the lifecycle choice.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 15:30:04 +02:00
schalli cc57de7313 feat(tender-radar): add settings gear link on module page
Tessera CI/CD / Lint & Type Check (push) Successful in 43s
Tessera CI/CD / Tests (push) Successful in 49s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m26s
The tender-radar settings page (with the E-Mail-Alerts form) was only
reachable by manually typing the literal URL /modules/tender-radar/settings
— no on-screen link existed, and appending /settings to the dynamic
[category]/[moduleSlug] URL returns 'Modul nicht gefunden'. Add a gear-icon
Link (mirroring dkv-fleet's pattern) pointing at the literal settings route,
plus a tenderRadar.page.settingsTitle key in de/en.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 15:10:46 +02:00
schalli c8bf229524 docs(14): phase verification — 4/5 verified, criterion 2 (EWS live) human_needed
Tessera CI/CD / Lint & Type Check (push) Successful in 48s
Tessera CI/CD / Tests (push) Successful in 48s
Tessera CI/CD / Build & Publish Images (push) Successful in 7s
Alle Code-Pläne (14-01..14-05) fertig + getestet (389/389 API, 151/151 web,
tsc clean). D-13 Mandanten-Isolation (write ownerTenantId create-only +
read OR-filter + getTender 404-guard) im Code bestätigt, Migration nullable
ohne Backfill. Einziger offener Punkt: 14-03 Task 4 = Live-EWS-Test an echtem
Postfach (human_verification, kein Code-Gap).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 14:28:01 +02:00
schalli eff5d26413 docs(14-05): complete i18n rollout for tender-radar plan
Tessera CI/CD / Lint & Type Check (push) Successful in 47s
Tessera CI/CD / Tests (push) Successful in 55s
Tessera CI/CD / Build & Publish Images (push) Successful in 1m51s
2026-07-23 14:23:23 +02:00
schalli 849aa9b0a9 feat(14-05): convert tender-radar settings + config forms to useTranslations
Converts settings/page.tsx, SourceConfigForm, RssFeedListForm and
EmailAlertConfigForm from hardcoded German strings to
useTranslations('tenderRadar'). Interval bound and RSS-feed removal
validation/error messages use next-intl interpolation ({min}/{max},
{label}). Updates the three affected settings component tests with a
next-intl useTranslations mock mirroring the marketplace test convention.
The entire tender-radar module UI (results, filters, saved searches,
detail, coverage, settings, RSS/email forms) now honors the selected
locale (CONFIG-03, D-10) with no language switcher added (D-11).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 14:20:52 +02:00
schalli 2ea02a2671 feat(14-05): convert tender-radar results-side components to useTranslations
Converts page.tsx, ResultsList, FilterPanel, SavedSearchBar, TenderDetail
and CoverageBanner from hardcoded German strings to
useTranslations('tenderRadar'). FilterPanel's Bundesland/CPV division
option labels are now looked up by stable code (NUTS-1 prefix / CPV
division code) while the underlying filter *value* sent to the backend
stays the canonical German string the API already matches against.
Portal display slugs (DÖE, DTVP, tender24, ...) in TenderDetail's
portalLabel() are left untranslated as proper-noun identifiers, not UI
copy. Updates the four affected component tests with a next-intl
useTranslations mock mirroring the marketplace test convention. Also
fixes an unrelated `t` parameter shadowing the translations function
inside ResultsList's triage batch-fetch (Rule 1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 14:15:33 +02:00
schalli d73171b052 feat(14-05): add tenderRadar i18n namespace (DE + EN) with key-parity guard
Introduces the tenderRadar top-level namespace in de.json/en.json covering
page, results, filter (incl. Bundesland/CPV division labels), savedSearch,
detail, coverage, settings, sourceConfig, rssFeeds and emailAlerts groups.
EN translations authored with consistent Vergabe-domain terminology
(Ausschreibung->tender, Vergabestelle->contracting authority, Frist->
deadline, Auftragswert->estimated value). tenderRadar-parity.spec.ts
enforces recursively-flattened de/en key-set equality so no follow-up edit
can silently add a string to only one locale.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 14:07:41 +02:00
schalli a4889f8399 docs(14-04): complete denylist transparency plan 2026-07-23 14:01:12 +02:00
schalli 947325f6ad feat(14-04): CoverageBanner "manuell beobachten" denylist block
- Add DenylistedPortal type + fetchDenylistedPortals() to
  tender-radar-api.ts, following the existing credentials:'include' fetch
  convention
- CoverageBanner fetches the denylisted-portals endpoint on mount and
  renders vergabe24/aumass with direct links (rel="noopener noreferrer",
  target="_blank"); block renders independently of the onlyDoe coverage
  note and fails silently on fetch error
- Add CoverageBanner.test.tsx asserting both portal hrefs, independence
  from the coverage note, and fail-silent behavior

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 13:59:44 +02:00
schalli 28c6c7fee1 feat(14-04): denylisted-portals read endpoint sourced from DENYLISTED_PORTALS
- Add PORTAL_URLS map (vergabe24, aumass) in source-registry.ts, keyed off
  the existing DENYLISTED_PORTALS constant so the portal set is never
  re-declared
- Add GET /modules/tender-radar/denylisted-portals, declared before
  @Get(':id') (route-order pitfall), mapping over DENYLISTED_PORTALS
- Extend tenders.controller.spec.ts: response shape + route-order guard

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 13:58:25 +02:00
schalli c140d1186a docs(14-03): pause after Tasks 1-3 — Task 4 (live EWS human-verify) OPEN 2026-07-23 13:56:00 +02:00
schalli 48e12523f3 feat(14-03): add email-config admin routes + D-13 read-side visibility filter + EmailAlertConfigForm
buildTenderWhere gains an optional ownerTenantId param: a resolved
requesting tenant sees global tenders (null) plus its own private ones
(OR[global, mine]); an unresolved requester fails CLOSED to global-only —
never an accidental cross-tenant leak.

TendersController: listTenders/getTender resolve the requesting tenant
leniently from the auth context (resolveRequestingTenantId, never throws)
and apply the D-13 filter; getTender 404s (not a distinct "forbidden") when
a tender's non-null ownerTenantId doesn't match the requester, so no
cross-tenant detail leak. New GET/PUT /modules/tender-radar/email-config
routes (Roles ADMIN/SUPER_ADMIN, tenantId from auth context, never the
body) delegate to TenderEmailConfigService — declared before @Get(':id')
per the project's NestJS route-order convention.

Web: EmailAlertConfig type + fetchEmailConfig/saveEmailConfig client
functions; EmailAlertConfigForm mirrors the DKV InboxConfigForm (password
blank on load, only sent when typed — T-07-12), added as a new
"E-Mail-Alerts" section on the existing tender-radar settings page.
Hardcoded German strings — i18n is Plan 14-05.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 13:53:17 +02:00
schalli 1be6b15249 feat(14-03): add per-tenant encrypted TenderEmailConfig + ownerTenantId write-side (D-13)
Prisma: new TenderEmailConfig model (per-tenant, tenantId @unique, mirrors
DkvModuleConfig) + Tender.ownerTenantId nullable column + index (D-13:
null = global/platform-wide, unchanged for all existing rows and every
public source; set = visible only to that tenant). Migration
20260723113917_tender_email_config_owner_tenant_id applied locally.

TenderEmailConfigService: safe-select admin CRUD (GET never returns the
password, only hasPassword — T-07-12) with DkvService's encrypt-preserve-
empty semantics, via CalendarCryptoService (AES-256-GCM).

RawTenderRecord/NormalizedTenderFields gain optional ownerTenantId,
threaded through TenderNormalizerService.assemble() unchanged.
TenderDedupService's CREATE branch writes ownerTenantId (defaulting to
null); the UPDATE branch deliberately never references it, so a tender
later also seen on a public source is never retroactively hidden.

EmailAlertAdapter.fetchTenders() now does the real per-tenant fan-out:
findMany({isActive:true}) across ALL tenants (deliberate, documented
cross-tenant platform-scheduler read, never forTenant()/RLS), decrypts
each tenant's credentials, picks imap/exchange provider, and tags every
extracted candidate with ownerTenantId — catch-per-tenant so one broken
mailbox never blocks the others.

tenders.module.ts: imports CalendarModule/InboxModule, registers
EmailAlertAdapter + TenderEmailConfigService, seeds an 'email-alert'
TenderSourcePollConfig row (pollGranularity='tick', isActive=false —
no default mailbox to activate yet, D-02 framework-ready stance).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 13:45:11 +02:00
schalli 8983231196 feat(14-03): add EmailAlertAdapter generic extraction + 'email-alert' normalizer dispatch
GREEN phase (TDD) for Task 1: extractCandidateLinks (cheerio a[href] +
footer-noise filter + MAX_LINKS_PER_EMAIL cap, plaintext regex fallback),
titleFromEmail (subject -> first body line -> fallback), and
sourceNoticeIdFor (sha256 link hash) implement D-04's generic, no-portal-
specific-parser evaluation of alert emails.

SourceType gains 'email-alert'; TenderNormalizerService routes it through
the existing normalizeBag() path (same as ai-netserver/cosinex-dtvp/rss).
EmailAlertAdapter.fetchTenders() is a Task-1 placeholder — Task 2 wires the
real per-tenant fan-out.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 13:38:36 +02:00
schalli 4d6fbb136e test(14-03): add failing spec for email-alert generic link/subject extraction
RED phase (TDD) for Task 1: pure-function tests for extractCandidateLinks,
titleFromEmail, sourceNoticeIdFor — covers HTML + plaintext bodies,
footer-noise removal, link cap, and D-04 no-portal-specific-parser restraint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 13:37:37 +02:00