- 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
- 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)
- 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
- 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
- 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
- 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)
- 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
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>
- 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>
- 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>
- 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>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
- 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>
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>