Adds Tender.cpvDivisions String[] (@@index Gin) derived at ingestion time
via cpv-catalog.ts's divisionOf() — replaces exact-match cpvCodes
comparison with a typesafe, GIN-indexable hasSome target (FILTER-03,
Pitfall 2). Backfill migration 20260721150000_tender_cpv_divisions_backfill
applied locally: 1612/1671 rows populated across all observed divisions
(741 rows carry division '45' — Bauarbeiten); idempotent (second run:
UPDATE 0, ADD COLUMN IF NOT EXISTS / CREATE INDEX IF NOT EXISTS both skip
cleanly). Applied via docker exec psql + `prisma migrate resolve
--applied` + `prisma generate` against the local dev DB only — no
Docker deploy on the test server.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the Phase-10-deferred `bundesland = null` assignment with
bundeslandFromRegion(region) so new ingests are Bundesland-filterable
immediately. Add @@index([bundesland]) for filter performance. New
handwritten migration 20260721140000_tender_bundesland_backfill backfills
the ~1671 pre-existing rows (idempotent UPDATE, only where bundesland IS
NULL AND region IS NOT NULL) — applied locally via
`docker exec tessera-ctl-db-1 psql`, resolved as applied in
_prisma_migrations, and prisma generate re-run.
Verified on the local dev DB: 933/1671 rows now have bundesland set
across all 16 Länder (738 remain NULL where region itself is NULL).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a per-username denylist so individual accounts (service accounts like
administrator, krbtgt, guest, ldap$) can be excluded from LDAP sync,
independent of the group/OU include-filter which only scopes the search.
- schema: LdapConfig.userExcludeList String[] (+ migration)
- sync: skip excluded usernames (case-insensitive) before recording the DN,
so an already-imported user added to the list gets deactivated next sync
- DTO / config service / controller / scheduler: thread userExcludeList through
- web: exclude-list admin UI section (add/remove/save) + de/en translations
- tests: 3 specs covering empty list, case-insensitive skip, deactivation
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Username lookups (login, admin seed, LDAP sync) compared case-sensitively
against a stored value with whatever casing it was created with, so
"Admin" and "admin" were treated as different accounts.
Normalizes at every write and read path: UserService.create/update
lowercase the username before persisting, findByUsername lowercases
the lookup input, AuthService.validateUser lowercases before the login
query, AdminSeedService lowercases the configured admin username, and
the LDAP sync loop lowercases the mapped sAMAccountName before using it
for lookup/create/update -- so AD casing differences don't create
duplicate accounts either.
Added a data migration to lowercase any existing mixed-case usernames.
It relies on the User.username unique constraint to fail loudly if two
existing accounts would collide after normalizing, rather than silently
merging them.
Verified locally: logged in with "ADMIN" (uppercase) against the
existing lowercase "admin" account after rebuilding the API image.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
bindDn and bindPassword are now optional on LdapConfig (nullable
migration) and throughout the DTOs/service/client -- an admin can
leave both blank to connect to directories that permit anonymous
read access. LdapService.bind() falls back to an RFC 4513 anonymous
bind (empty DN + empty password) whenever either field is missing,
shared across testConnection, listGroups, and syncUsersForTenant.
Frontend: removed the required attribute from Bind-DN/Bind-Passwort,
added a placeholder hint ("leer = anonymous bind"), and the
"Verbindung testen" button now only needs a Server-URL to enable
(not bindDn+bindPassword). Config responses now return bindPassword
as null (not a misleading "********") when no password is set.
Verified locally: submitted only a Server-URL with both bind fields
empty and confirmed the request reached the anonymous-bind code path
(DNS failure for the unreachable test host, not a validation error).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
FavoriteLink existed in schema.prisma but was never captured in a
migration -- same bug class as the DashboardLayout fix in
20260629130000_add_missing_tables. It silently worked in local dev
(table created via db push) but any environment relying on
`prisma migrate deploy` never got the table, causing every
GET /favorites request to 500 with PrismaClientKnownRequestError
P2021 ("table does not exist").
Found live testing the production deploy at alpha.tessera.ctl.de:
adding a Favoriten widget triggered the 500. Confirmed via server
logs (docker logs) and by inspecting _prisma_migrations / \dt on
that database.
CREATE TABLE/INDEX IF NOT EXISTS makes this safe to apply against
environments where the table already exists untracked (verified: ran
clean against local dev's DB, which already had the table from a
prior db push, with zero errors and zero data loss).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Persists per-tenant AD group/OU DNs to restrict which directory
entries get synced. Empty array (default) preserves current
behavior — import everyone under base DN.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Column already existed in production DB — migration failed with 42701.
Hotfixed via psql UPDATE on _prisma_migrations; migration SQL updated
to prevent recurrence on fresh deploys.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Column was added to schema but migration was missing, causing
PrismaClientKnownRequestError P2022 on prod API startup.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add avatarPath String? column to User model (migration: add_user_avatar)
- POST /users/me/avatar: 2MB limit, image/png/jpeg/webp allowlist, writes to user-files/avatars/{userId}.{ext}
- GET /users/me/avatar: streams avatar with Cache-Control: no-store
- AuthService.getMe(): returns isLocalUser + hasAvatar without leaking passwordHash/ldapDn
- AuthController GET /auth/me: now returns enriched profile via getMe()