Commit Graph

307 Commits

Author SHA1 Message Date
schalli 2779d42e6c fix(16): WR-04 normalize the rename-vs-unchanged comparison
syncBoundGroupsForTenant() compared cn/dn for byte equality, so any
casing difference AD returns between two runs (e.g. after a
domain-controller switch) would look like a rename and re-write
name/ldapDn every single sync — violating the 'sync twice over an
unchanged AD state = no-op' idempotency guarantee. The comparison used
to DECIDE 'is this a rename' is now case-insensitive; the value written
on an actual rename is still stored byte-for-byte as the directory
reports it, per D-03.
2026-08-06 16:58:29 +02:00
schalli 19717954d6 fix(16): WR-02 discriminate P2002 target in group rename branch
syncBoundGroupsForTenant()'s rename write updates name and ldapDn in one
call, so a P2002 there can come from either @@unique([tenantId, name])
or @@unique([tenantId, ldapDn]). The catch previously reported every
P2002 as a name collision unconditionally; it now inspects
err.meta.target the same way importGroupsByDn() already does for its
own create() call, so a non-name unique violation is no longer
mislabelled and sent the admin down the wrong troubleshooting path.
2026-08-06 16:57:34 +02:00
schalli dd59bf592f fix(16): WR-01 name lock in GroupsService.update() also checks ldapDn
A legacy binding from plan 15-06 has ldapDn set but ldapObjectGuid stays
null until the first syncBoundGroupsForTenant() run backfills it. Until
then, GroupsService.update() let a direct PATCH rename through even
though GroupFormModal.tsx already treats the same group as AD-bound
(isImported = ldapDn != null) — the D-03 name lock was only a UI
convention for that window, not the backend invariant the 16-02 summary
claimed.
2026-08-06 16:56:30 +02:00
schalli 68aca81f71 feat(16-03): wire group reconciliation before membership sync (5a)
- syncUsersForTenant() now calls syncBoundGroupsForTenant() (5a) BEFORE
  syncGroupMembershipsForTenant() (5b) — the central correctness ordering
  of Phase 16 (RESEARCH.md Pitfall 1): a rename detected in the same run
  must be written back before the memberOf filter is built, or the
  membership sync would misreport a rename as a membership wipeout
- Add observable ordering test (call-order spies), a no-op-guard test, and
  a regression test proving a memberOf search never uses the stale
  pre-rename DN
- Update the D-21 membership-sync test fixtures to resolve step 5a as a
  deterministic no-op (DN-derived identity GUID), since the wiring now
  runs 5a ahead of every syncUsersForTenant() call those tests exercise

A1 (objectGUID survives an AD rename) and A2 (binary filter escape syntax)
remain unverified against a real directory — no reachable AD in this
sandbox. Documented as an outstanding live verification in the plan
SUMMARY, not silently skipped.
2026-08-06 16:21:10 +02:00
schalli 522293417a feat(16-03): add syncBoundGroupsForTenant reconciliation method
- New private LdapService.syncBoundGroupsForTenant(): rename detection
  (SC-3), disappearance deletion with default-marker handoff before delete
  (SC-4/D-05/D-06), legacy ldapDn-only binding GUID backfill (D-07), and a
  32-hex-char guard before any objectGUID filter interpolation (T-16-01)
- LdapSyncResult grows additively: groupsAdopted, groupsRenamed,
  groupsDeleted, defaultMarkerMoved
- LdapService constructor takes GroupsService; LdapModule imports
  GroupsModule (no cycle)
- 14 new test cases covering the full behavior matrix plus idempotency
2026-08-06 16:15:09 +02:00
schalli f71e614f7f feat(16-02): display name with fallback in user-detail projections (D-04, UI-SPEC Surface Contract 6)
- ModuleGrantsService.getUserAccess() now selects internalName on the
  membership query's group projection (already present via `include:
  { group: true }` on the grant query)
- Both display points (viaGroups names, membership chips' name field)
  use internalName ?? name; groups[] sorting now runs over the
  displayed name as a result, distinct from GroupsService.listForTenant()
  which still sorts by the raw name column
- 3 new test cases: fallback set/unset, sort-by-displayed-name
- No apps/web/ changes (verified via git diff --name-only)
2026-08-06 16:00:18 +02:00
schalli 253da91ba9 feat(16-02): server-side name lock for imported groups + internalName (D-03/D-04/D-07)
- GroupsService.update() rejects `name` with BadRequestException when the
  loaded group carries a set ldapObjectGuid (imported groups) — a real
  backend invariant, not a UI-only disable
- internalName is settable/clearable on any group; empty/whitespace-only
  values normalize to null instead of an empty display name
- listForTenant() now projects internalName alongside name
- UpdateGroupDto drops ldapDn (D-07: no more codepath binds a local group
  to AD via this route) and gains internalName?: string | null
- 9 new test cases in groups.service.spec.ts (name lock, internalName
  set/clear/idempotent/local-group/unicode, listForTenant projection);
  stale ldapDn update() test removed (behavior intentionally deleted)
2026-08-06 15:58:28 +02:00
schalli 2ef9b8638c feat(16-02): standard group handoff building block (D-06)
- DEFAULT_GROUP_NAME extracted as shared constant between
  ensureDefaultGroup() and the new reassignDefaultBeforeDelete()
- reassignDefaultBeforeDelete(tenantId, groupId) moves the default
  marker deterministically (DEFAULT_GROUP_NAME first, else oldest
  other group by createdAt asc), never deletes, never throws
- 6 test cases covering handoff, fallback ordering, no-other-group,
  non-default no-op, cross-tenant no-op, and P2002 race
2026-08-06 15:55:53 +02:00
schalli 626e29659d feat(16-01): apply Group.internalName/ldapObjectGuid migration to local DB
- Migration 20260806133916_add_group_internal_name_and_object_guid applied
  against the local Postgres container (baselined 24 prior migrations first
  — _prisma_migrations was missing, unrelated to this task's DDL)
- New describe block in migration-sql.spec.ts pins internalName,
  ldapObjectGuid, and the (tenantId, ldapObjectGuid) unique index
- Full API test suite green (40 files, 526 tests)
2026-08-06 15:43:19 +02:00
schalli 3523e43a13 feat(16-01): tracer — select and import AD groups end-to-end
Task 1 checkpoint resolved: approve-both, granted 2026-08-06 by the
project owner (D-04 one-way schema extension: Group.internalName +
Group.ldapObjectGuid, both nullable, one versioned migration).

Adds the Phase 16 tracer slice through every layer:
- Prisma schema: Group.internalName, Group.ldapObjectGuid,
  @@unique([tenantId, ldapObjectGuid]) (Prisma client regenerated;
  the versioned migration itself is Task 3, separately blocking).
- LdapService: listGroups() now reads objectGUID via
  explicitBufferAttributes and flags alreadyImported per tenant;
  new importGroupsByDn() creates a Group per checked DN with
  name/ldapDn/ldapObjectGuid, reject-with-report on name collision
  (P2002 on name -> nameCollisions, P2002 on ldapObjectGuid ->
  skipped), never aborts the batch on one DN's error; new static
  escapeLdapFilterBuffer() for Plan 16-03's later existence sweep.
- DTO/controller: ImportGroupsDto, POST /ldap/groups/import
  (ADMIN/SUPER_ADMIN), listGroups route now tenant-scoped.
- Frontend: new "AD-Gruppen importieren" section in /admin/ldap,
  own discovery/import handlers with a visible error state
  (Owner decision 2026-08-06 — no silent catch{} for these two
  handlers), i18n keys in de.json/en.json.
- Tests: 8 new cases covering the full <behavior> list plus
  listGroups sort order and alreadyImported.

Flagged assumption (RESEARCH.md A1/A2): objectGUID rename-stability
and the binary filter syntax are unverified against a real AD —
this plan only WRITES the GUID, Plan 16-03 reads it back live.
2026-08-06 15:09:35 +02:00
schalli 0d7d8a597e feat(260805-fok): beide Mandanten-Entstehungspfade verdrahten + Startup-Reparatur
- TenantService.create ruft nach prisma.tenant.create ensureDefaultGroup
  auf; Fehler werden protokolliert, nicht propagiert (Muster aus
  UserService.create)
- tenant.module.ts importiert GroupsModule (keine Zirkularitaet, wie
  UserModule bereits vormacht)
- AdminSeedService.onApplicationBootstrap besteht jetzt aus zwei
  sequenziellen await-Schritten: seedAdmin() (bisheriger Rumpf, plus
  ensureDefaultGroup nach dem Tenant-Upsert und VOR user.create), dann
  ensureDefaultGroupsForAllTenants() als abschliessende Reparatur ueber
  ALLE Mandanten — laeuft unabhaengig von seedAdmin()s fruehen
  Rueckkehrpfaden (fehlende ENV / Admin existiert bereits) und ist je
  Mandant sowie insgesamt try/catch-gekapselt, blockiert den API-Start nie
- Reparatur sitzt bewusst NICHT als eigener onApplicationBootstrap-Hook
  in GroupsModule (Ordering-Falle aus tender-scheduler.service.ts)
- tenant.service.spec.ts, admin-seed.service.spec.ts (neu): Reihenfolge,
  beide fruehen Rueckkehrpfade, Fehlerisolation je Mandant, Idempotenz
  ueber zwei Bootstrap-Laeufe
2026-08-05 11:30:49 +02:00
schalli 9d1254cd78 feat(260805-fok): GroupsService.ensureDefaultGroup(tenantId)
- Neue Methode ensureDefaultGroup: legt fuer einen Mandanten ohne jede
  Gruppe die Standardgruppe 'Alle Benutzer' (isDefault:true) an, nimmt
  alle Bestandsbenutzer als MANUAL-Mitglieder auf und erzeugt Grants
  fuer alle aktiven Module — derselbe Endzustand wie die drei
  Backfill-INSERTs der Migration 20260804130130
- Waechter prueft ausschliesslich group.count === 0, niemals die
  fehlende isDefault-Markierung (D-13)
- P2002 aus dem partiellen Index Group_one_default_per_tenant wird
  abgefangen und liefert null statt zu werfen (Race-Sicherheit)
- groups.service.spec.ts: Fake erweitert um group.count,
  tenantModuleActivation, moduleGrant.findMany/createMany,
  $transaction mit Callback-Form, plus voller ensureDefaultGroup-Testblock
2026-08-05 11:28:16 +02:00
schalli ecadf69e14 feat(260805-d0r): getUserAccess returns groups from GroupMembership (D-16)
- New groupMembership.findMany query, tenant-scoped via group.tenantId
  (GroupMembership has no own tenantId column)
- Response shape changes from an array to { groups, modules }; modules
  entries stay field-identical to before
- Group without any module grant now stays visible, closing the
  reproduced defect
2026-08-05 09:33:51 +02:00
schalli b6d4e4acb6 test(260805-d0r): add failing tests for groups in getUserAccess
- Regression: user in a group without any module grant stays visible
- Cross-tenant: membership in a foreign tenant's group is excluded
- Origin (MANUAL/LDAP), empty-modules case, stable alpha sort
2026-08-05 09:33:22 +02:00
schalli 1c32543f58 feat(15-03): GET /modules/catalog — beide Statusflags in einer Antwort
- ModuleAccessService.getCatalogFlags(tenantId, userId, role) liefert je
  aktivem Modul isActiveForTenant + hasAccess in einer Auflösung
- ModuleRegistryController.findCatalog (GET /modules/catalog), erreichbar
  für jeden authentifizierten Benutzer wie GET /modules (D-08)
- ADMIN/SUPER_ADMIN: hasAccess immer wahr für aktive Module (D-03)
- 4 neue Tests für getCatalogFlags
2026-08-04 18:41:23 +02:00
schalli 072fb7f62f feat(15-03): ModuleGrantsController und Einbindung in GroupsModule
- GET /module-grants/matrix, GET /module-grants/users/:userId,
  POST /module-grants, DELETE /module-grants — alle vier rollengeschützt
  (RolesGuard + Roles ADMIN/SUPER_ADMIN)
- matrix vor users/:userId deklariert (Beschattungsfehler-Vermeidung)
- GroupsModule bindet ModuleGrantsController/-Service ein; kein Import
  von ModuleRegistryModule nötig, da der Service nur PrismaService braucht
2026-08-04 18:41:17 +02:00
schalli 5e256db01d feat(15-03): ModuleGrantsService — Freigaben setzen/entziehen mit Mandanten-Gegenprüfung
- assertTargetBelongsToTenant prüft groupId/userId aus dem Request-Body
  gegen tenantId aus dem JWT (T-15-01), vor jedem Grant-Insert
- grant: Entweder-oder-Regel (D-04), aktive TenantModuleActivation (D-02),
  P2002 als Erfolg (Doppelklick-Schutz)
- getMatrix (D-15) und getUserAccess (D-16) für Matrix-Seite und
  Benutzer-Detail, jeweils sortiert und mandantengescoped
- 20 Tests inkl. adjacency/empty/ordering/idempotency/concurrency
2026-08-04 18:41:12 +02:00
schalli 614de2815a feat(15-04): AD-Gruppenmitgliedschafts-Abgleich im bestehenden LDAP-Sync
- LdapService.syncGroupMembershipsForTenant (neu, privat): pro AD-gebundener
  Group (ldapDn gesetzt) ein memberOf-Reverse-Query je Base-DN, nie ein
  Attribut-Lesen (Range-Retrieval-Pitfall). GroupMembership(source: LDAP)
  wird per createMany/skipDuplicates angelegt (lässt bestehende MANUAL-Zeilen
  unangetastet, D-19/D-20) und per deleteMany(source: 'LDAP', notIn: [...])
  bereinigt. Jede Gruppe läuft in eigenem try/catch, ein Fehler landet als
  "Gruppe <name>: <message>" in result.errors, die Schleife läuft weiter.
- Aufruf in syncUsersForTenant nach der Deaktivierungsschleife (Schritt 5)
  und vor lastSyncAt (Schritt 6) — hinter dem bestehenden Base-DN-No-Op-Wächter,
  kein separater Job, kein zweiter Button (D-21).
- LdapSyncResult um groupMembershipsAdded/groupMembershipsRemoved erweitert.
- ldap.service.spec.ts: neuer describe-Block mit 13 Tests (adjacency, empty,
  encoding, ordering, idempotency, concurrency/backstop) plus Anpassung der
  drei bestehenden Prisma-Fixtures und einer Ergebnis-Assertion an die
  erweiterte LdapSyncResult-Form.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 15:50:42 +02:00
schalli 0ff46acd75 feat(15-05): getWidgets filters via ModuleAccessService (D-22, PERM-07)
- DashboardModule imports ModuleRegistryModule to inject ModuleAccessService
- getWidgets(userId, tenantId, role) runs the existing findMany unchanged
  first, then calls getAccessibleModuleIds exactly once — only if a loaded
  widget's type is in WIDGET_MODULE_MAP (currently always empty, so no
  lookup runs today); unresolved module slugs fail closed
- DashboardController.getWidgets forwards tenantId + role from the JWT
- dashboard.service.spec.ts (8 tests, TDD-GREEN): covers every <behavior>
  case incl. D-03 ADMIN bypass, adjacency/empty/ordering/idempotency, and
  fail-closed on an unresolved Module slug
- pnpm --filter @tessera/api test: 457/457 green; type-check clean
- manual e2e against local API + DB container: empty WIDGET_MODULE_MAP
  leaves an existing user's widget count unchanged (2/2 clock+search
  survived the filter), throwaway verification user/rows removed after
2026-08-04 15:37:28 +02:00
schalli d0ff6f0bc0 test(15-05): add failing test for module-filtered getWidgets
- covers every <behavior> case from 15-05-PLAN.md task 2, including the
  adjacency/empty/ordering/idempotency edge-probe categories and the
  fail-closed unresolved-slug case
- RED confirmed: 4/8 fail against the current 1-arg getWidgets(userId)
2026-08-04 15:31:35 +02:00
schalli 954cd6e171 feat(15-05): static widget-to-module registration table
- WIDGET_MODULE_MAP + getModuleSlugForWidgetType (apps/api/src/dashboard/widget-module-map.ts)
- table intentionally empty at end of phase: all 8 existing widget types are module-free platform widgets (D-22)
- code constant chosen over a WidgetInstance schema column — no migration for a field empty on every row
2026-08-04 15:30:51 +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 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 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 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 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 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 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
schalli 48a2dc1026 feat(14-02): add RSS feed admin routes, API client, and settings UI
Adds GET/POST/DELETE /modules/tender-radar/rss-feeds (Roles-guarded
ADMIN/SUPER_ADMIN), declared before the existing @Get(':id') handler to
avoid NestJS route-order shadowing. Delegates to
TenderRssFeedSourceService; the denylist/SSRF rejection (D-14) surfaces
as a 400 unchanged.

Web: tender-radar-api.ts gains listRssFeeds/createRssFeed/deleteRssFeed
(relaying the backend's specific rejection message via
extractErrorMessage), and a new RssFeedListForm client component renders
an "RSS-Feeds" section on the tender-radar settings page (D-09) — list,
add (with inline denylist error), and remove global feed URLs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 13:29:07 +02:00
schalli e812738c3a feat(14-02): add TenderRssFeedSource CRUD, tick poll gate, and wire RssAdapter
Global admin-managed RSS feed list (TenderRssFeedSource, D-08/D-14) with
a save-time hostname/SSRF guard (TenderRssFeedSourceService) — RSS feed
URLs are runtime admin input, so the code-level SourceRegistry denylist
gate does not cover them; a separate check rejects DENYLISTED_PORTALS
hostnames, non-http(s) schemes, and private/loopback hosts.

Adds TenderSourcePollConfig.pollGranularity ('day' | 'tick', D-15):
pollDueSources() branches per source — 'day' sources keep the existing
lastIngestedDay gate byte-unchanged, 'tick' sources (rss) fetch on every
active scheduler tick regardless of lastIngestedDay, since the day-cursor
gate was built for a genuine daily batch-export API and would otherwise
silently cap RSS to one fetch per calendar day.

Wires RssAdapter.fetchTenders() to fan out over active feed rows (native
fetch + AbortController 15s + response-size ceiling, catch-per-feed),
registers it in tenders.module.ts, and seeds the 'rss' poll config
active with pollGranularity='tick' plus a default-active service.bund.de
feed row (subreport-elvis has no single canonical URL — zero rows seeded,
admin adds relevant municipality feeds).

Migration applied locally per project convention (host -> container IP).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 13:24:36 +02:00
schalli 3a96cbbbe6 feat(14-02): add RssAdapter.parseFeed + 'rss' normalizer dispatch
Fixture-first RSS parsing (INGEST-04): parses live-captured
service.bund.de (pubDate present, numeric-HTML-entity titles) and
subreport-elvis (pubDate absent, CDATA titles) feed shapes into
RawTenderRecord[] via fast-xml-parser, mirroring the DoeOpenDataAdapter
config. SourceType extended with 'rss'; normalize() dispatches 'rss'
through the existing normalizeBag() path unchanged (D-04/D-05).

Rule 1 fix: fast-xml-parser only decodes the 5 predefined XML entities,
not numeric character references — added an explicit decode step so
service.bund.de titles ("&#220;bermittlung...") render correctly
instead of leaking raw entity syntax.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 13:17:02 +02:00
schalli c404954bee feat(14-01): add fetchMessages() to InboxProvider + both implementations
- Add InboxMessage type (subject + html/text body) and fetchMessages() to
  the InboxProvider interface, ImapProvider, and ExchangeInboxProvider
- IMAP: findBodyParts() walks the MIME tree for first text/html + text/plain
  parts, reusing the connect/lock/search/fetchAll skeleton; marks \Seen
- EWS: new getItemBodySoap() requests item:Body, extracts BodyType via the
  existing extractAttr/extractAll helpers, marks IsRead via markReadSoap
- Net-new spec coverage (imap.provider.spec.ts, exchange-inbox.provider.spec.ts)
  mocking ImapFlow and httpntlm.post (via require.cache stub, since httpntlm
  is loaded with a raw require() that vi.mock cannot intercept)
- fetchPdfAttachments untouched in both providers (D-02); full API suite
  (301 tests) + tsc --noEmit stay green

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 13:09:45 +02:00
schalli eb668fd5c8 refactor(14-01): extract DKV inbox providers into shared inbox/ module
- Move ImapProvider, ExchangeInboxProvider, InboxProvider into apps/api/src/inbox/
- Move InboxConfig/InboxAttachment/InboxEmail into new inbox.types.ts
- dkv.types.ts re-exports the moved types so existing DKV imports keep compiling
- DKV switches import paths to ../inbox/... and imports InboxModule
- Pure move + import-path swap: fetchPdfAttachments and all DKV logic unchanged (D-01/D-02)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 13:02:15 +02:00
schalli 988100576f test(quick-260723-e7i): cover ai-netserver/cosinex bag normalization + DOE regression
- bagRecord() helper builds inline RawTenderRecords for the flat ocdsPayload
  bag shape (no fixtures exist for NetServer/cosinex-DTVP)
- ai-netserver and cosinex-dtvp full-bag mapping, null/empty-field fallback,
  and contentHash-format assertions
- Existing DOE fixture-based assertions untouched, confirming the refactor
  didn't change DOE-path behavior

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 10:19:00 +02:00
schalli 82c9a48305 feat(quick-260723-e7i): per-sourceType dispatch in TenderNormalizerService
- Extract shared assemble() tail (status/dedupKey/contentHash/publishedAt)
  so it is computed identically across all sources, not duplicated
- Move existing DOE eForms/OCDS extraction into normalizeDoe() (byte-identical
  behavior, regression guard)
- Add normalizeBag() for the flat ocdsPayload bag shared by the NetServer and
  cosinex/DTVP scraper adapters ({title, buyerName, procedureType,
  legalFramework, deadlineAt}); legalFramework deliberately not mapped
- normalize() dispatches on raw.sourceType, defaulting to the DOE path so the
  additive SourceType union never throws

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 10:18:16 +02:00
schalli 6fe0dd07fe feat(13-05): register CosinexAdapter as tenders module provider
Adds CosinexAdapter to TendersModule's providers and registers it with
SourceRegistry at DI boot, alongside DoeOpenDataAdapter/NetServerAdapter
(cosinex-dtvp is not AGB-denylisted, so registration succeeds). Seeds a
cosinex-dtvp TenderSourcePollConfig row with isActive: false, matching
the ai-netserver "framework ready, activation deferred" stance (D-02).

tsc --noEmit clean; src/tenders slice: 21 files, 221/221 tests pass
(205 pre-existing + 16 new cosinex.adapter.spec.ts).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 09:49:19 +02:00
schalli 12fc5ac02d feat(13-05): add cosinex/DTVP source adapter with fixture tests
Separate HTML adapter for the cosinex Vergabemarktplatz (DTVP) satellite
(sourceType='cosinex-dtvp'), distinct from the NetServer adapter since
cosinex markup differs structurally. Live inspection (2026-07-23) found
the "Aktuelle Bekanntmachungen" results table is fully server-rendered
(not JS-dependent as D-01 anticipated), so selectors are fully populated
rather than falling back to a needs-JS stub — parses publish date,
deadline (or "nv"), title, legal framework/procedure type, buyer name,
and a real per-notice deep link (pid) into RawTenderRecord[].

Rule 1 fix: cosinex serves charset=ISO-8859-1 with raw Latin-1 bytes for
umlauts (not HTML entities); Response.text() always UTF-8-decodes per
the Fetch spec, so the adapter reads arrayBuffer() and decodes explicitly
via TextDecoder('iso-8859-1') to avoid mojibake.

16 spec tests pass against a live-captured fixture (20 rows, transcoded
to UTF-8 on disk): full-fixture parse, deadline/publish date parsing,
nested-<abbr> procedure-type extraction, umlaut decoding, empty/broken
HTML and missing-pid row fallback, fetch-throw/non-2xx fallback, no-axios
and no-input-interpolated-URL guards.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 09:48:21 +02:00
schalli 88572f5694 feat(13-04): NetServerAdapter im Modul registriert (INGEST-02)
- NetServerAdapter als Provider ergaenzt, additiv neben DoeOpenDataAdapter
  in onModuleInit ueber SourceRegistry.register() registriert (Denylist-
  Gate erlaubt tender24/lhs-vpbw/vergabe.landbw, keine auf der Denylist)
- TenderSourcePollConfig-Row fuer 'ai-netserver' geseedet, isActive:false
  (Aktivierung bleibt Admin/Seed-Entscheidung, D-02)
- tsc --noEmit clean, src/tenders-Suite 20 Dateien/205 Tests gruen

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 09:14:24 +02:00
schalli 043ada9438 feat(13-04): NetServer-Adapter fuer tender24/lhs-vpbw/vergabe.landbw (INGEST-02)
- Package-Legitimacy-Checkpoint bestaetigt: cheerio (sauberer Gate-Pass,
  27M Downloads/Woche) statt node-html-parser installiert
- EIN config-getriebener NetServerAdapter bedient alle 3 AI-AG-Portale
  via PublicationSearchControllerServlet-Trefferliste, parst <table> mit
  cheerio zu RawTenderRecord[], sourcePortal je Zeile korrekt
- sourceNoticeId = data-oid (stabil, per Zeile eindeutig); sourceUrl =
  Such-URL als dokumentierter Best-Effort-Fallback (kein Deep-Link ohne
  JS-Ausfuehrung ermittelbar, Open Question 2)
- Fehlertoleranz: try/catch pro Zeile + pro Portal, []-Fallback bei
  Totalausfall, AbortController 15s-Timeout, hardcodierte Portal-URLs
  (SSRF-Guard T-13-04-01)
- Live-gecapturte Fixture (tender24.de, 2026-07-23) mit Umlaut-/&amp;-
  Buyer, gefuellter und leerer Deadline; 14 Spec-Tests gruen

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 09:13:01 +02:00
schalli 166194fa04 feat(13-06): getTender include sources[] (SCHEMA-03 read surface)
GET /modules/tender-radar/:id now includes the TenderSource relation
(sourcePortal, sourceUrl, sourceNoticeId) so a cross-source-deduped
tender's detail response carries links to all its source portals, not
just the single primary sourceUrl column. Route order unchanged (:id
stays after all static routes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 08:55:02 +02:00
schalli 1d4f9cf1b3 feat(13-03): wire SourceRegistry + TenderDedupService into TendersModule
Register SourceRegistry and TenderDedupService as providers.
onModuleInit registers DoeOpenDataAdapter with the registry before the
scheduler's first tick — the DI-boot-time enforcement point for the
INGEST-07 denylist gate (D-06). This is Wave 2's sole writer of
tenders.module.ts; 13-04 (NetServer) and 13-05 (cosinex) add their
own registry.register(...) calls additively in later waves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 08:51:47 +02:00
schalli d453dbbd1f feat(13-03): pollDueSources fan-out over all active sources (SCHEMA-03)
Replace the DÖE-only findUnique with findMany({isActive:true}) fan-out
(poll-once-fan-out-many, D-01). Each active TenderSourcePollConfig is
resolved through SourceRegistry.get(sourceType) and processed inside
its own try/catch (catch-per-source, D-01) — one broken/blocking source
no longer aborts the tick for the others. dedupActive =
activePortalCount >= 2 (D-05) is computed once per tick and passed to
TenderDedupService.resolve(), which now replaces the direct
tender.upsert call. Delta-only matchDelta boundary (D-07) preserved:
only genuinely-created tender IDs across all sources are collected.

Extended tender-ingestion.service.spec.ts: multi-config fan-out,
catch-per-source isolation, dedupActive gate assertion, adapter-missing
skip, plus the existing SCHEMA-02/D-07/retention/day-cursor suites
updated to the new registry+dedup constructor shape (all green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 08:50:57 +02:00