API Consolidation Plan
Status: Phases 0–6 + 9a landed (`api-consolidation` merged as squash PR
Status: Phases 0–6 + 9a landed (api-consolidation merged as squash PR #815 → develop).
Phase 7 (contract-first) on branch api-consolidation-phase7: COMPLETE across EMS + CUMS + Partner. EMS 100%
(EventController CRUD + EventReportingApi; BulkOperationsApi removed). Partner meetings wired (new
partner-meetings-api generator). The 5 deferred Phase-7 non-wires are now resolved (2026-06-29):
GlobalSessionController was NOT dead (live FE consumer) → wired (new Company Sessions op, event-sessions);
dead ActivityHistoryApi op → removed from users-api; CUMS UserController 5 orphan endpoints + LogoController
(7 ops) + PublicOrganizerController → wired spec-first; DevEmail (@Profile("local")) + TestFixtureCleanup
(admin test util, JWT+ORGANIZER+regex, intentionally outside the public contract) → documented skip.
⚠️ CORRECTION (adversarial review 2026-06-29 — see the review section below): the “EMS 100% / only dev-test+Watch*
hand-rolled” wording is OVERSTATED. Verified still-hand-rolled/un-wired beyond the dev-test+Watch* set:
NotificationController (EMS, 7 production ops, no spec at all) and EventController’s 4 registration/stakeholder
ops (raw Map; generated RegistrationsApi orphaned). Treat the per-controller claims as ~95%, not 100%.
Phase 9 (tag/Javadoc hygiene) = DONE. Phase 8 = PARTIAL: 8.1 (/public/settings/features relocated off
AiAssistController → new event-app-settings-api + PublicSettingsController, path-preserving) landed;
8.2 (/attendee-portal/dashboard) stays deferred (attendee service dormant); 8.3 (Watch path normalization)
owner-skipped (generated WatchApi is unimplemented + the iOS app vendors its own spec → latent/harmless).
Date: 2026-06-25 (last status update: 2026-06-29)
Governing standard: ADR-013 — REST API CRUD Conventions
Scope: CUMS (companies, users), EMS (events, topics), Partner (partners + 4 sub-specs),
plus the shared-kernel contract that underpins all 13 specs.
This plan is the actionable counterpart to ADR-013. Each item is tagged
[REMOVE] / [MERGE] / [RENAME] / [KEEP-BUT-FIX] and ordered by risk so the low-risk,
high-clarity wins land first. Staging IS production — every contract-removing step must
verify no live caller first.
Post-implementation adversarial review (2026-06-29) + gap remediation (2026-06-30)
Update 2026-06-30: all 4 review gaps were addressed — gaps 1/3/4 fully fixed (NotificationController
wired, /partners/me typed, dead specs removed) and gap 2 closed for the raw-Map finding (the full
RegistrationsApi schema-divergence wire stays a separate FE-coordinated story). Original findings below.
A 5-agent adversarial review (each tasked to falsify the plan’s claims against the actual code)
ran after Phases 8–9. The core goal is substantially achieved — CUMS + Partner are fully
contract-first (every wireable controller implements <X>Api), the shared ErrorResponse/
pagination contract is single-sourced, the generator is on 7.14.0 (both pins — settings.gradle
plugin id + root build.gradle dependency), containerDefaultToNull is on every generator block,
BulkOperationsApi/ActivityHistoryApi are gone, partner specs are 5→2, and the milestone “both ends
generated from the spec” holds for the overwhelming majority of the surface. But the headline
"EMS 100% / every controller / ALL ad-hoc Maps typed / only dev-test+Watch hand-rolled" wording is
overstated.* Verified open gaps (file:line evidence; corroborated across agents + spot-checked):
- ✅ FIXED (2026-06-30):
NotificationControllerwired contract-first. Authoredevent-notifications-apispec +openApiGenerateNotificationsgenerator; the controller nowimplements NotificationsApi; the 7 hand DTOs were consolidated to generated twins (sharedNotificationMapper, Instant→OffsetDateTime, 4-field pagination preserved for a byte-identical wire). 23 tests + live smoke green. - 🟡 PARTIALLY FIXED (2026-06-30): the EventController registration raw-Maps are typed.
confirmRegistration→ConfirmRegistration200Response,resendConfirmationEmail→ResendConfirmationResponse,enrollStakeholders→EnrollStakeholdersResponse,updateRegistrationbody→UpdateRegistrationStatusRequest(spec made truthful; the oldPatchRegistrationRequestmodelled a never-implemented preference update). Still deferred: the fullRegistrationsApi@Override-wiring (the other ~8 read/create ops) — the generatedRegistrationschema renames wire fields (firstNamevs deployedattendeeFirstName) and list/create use different wrappers, so forcing it would break the public registration flow. That is a separate FE-coordinated spec-truth story (the schemas must be reconciled to the deployed wire first). - ✅ FIXED (2026-06-30):
/partners/metyped — documented in partners-api asgetMyPartnerCompanyreturningMyPartnerCompanyResponse;PartnerControllernow@Overrides it. Tests + live smoke green. - ✅ FIXED (2026-06-30): dead specs removed.
auth-endpoints.openapi.yml(the backend forgot/reset endpoints were retired — the FE now resets via Amplify→Cognito directly, confirmed inuseForgotPassword.ts) andfile-upload-api.openapi.yml(its/logos/*ops are now the wired companies-apiLogostag, served byLogoController). Verified no live caller / no generator / no FE target before deleting; fixed the dangling doc links (README, aws-ses/rate-limiting guides, companies-api comment). (Residual: two inert dead alternation branches in.githooks/pre-pushreference the removed filenames — harmless, never match a diff; left untouched as the hook is push-critical.) - Frontend hand wire-DTOs duplicating generated schemas:
useFeatureFlags.ts(✅ fixed 2026-06-29), plus still-pre-existingsrc/types/watch.ts(PairingCode/StatusResponse),src/types/user.ts,src/types/auth.ts(low priority; out of the contract-first backend scope). - Orphaned shadow DTOs (hand twin is live, generated twin unused): EMS
watch/dto/*+ CUMScompanyuser/watch/dto/*— the documented Watch* non-wire, cosmetic.
Plan-text accuracy nits (corrected below where editing): the “only dev/test two + Watch*”
headline omits NotificationController; InternalRsvpController is in partner-coordination, not
EMS; WatchWebSocketController is @Controller (STOMP), not @RestController; the parenthetical
generator counts (“EMS ×11, partner ×3”) are stale (now EMS 13, partner 4 after the app-settings +
partner-meetings generators).
Verification of Phases 8–9 themselves (all green, local dev): Bruno 14/14; Playwright full suite
240 passed / 4 flaky / 2 failed — both failures are pre-existing environmental flakes (one @quarantine
archive-scroll, one speaker-portal local-dev flake that passes in isolation), zero regressions from
the 8.1/9 changes.
Execution status — branch api-consolidation (updated 2026-06-26)
Landed = committed + pushed, green on what the local hooks actually run.
⚠️ Correction (Phase 6): the active hooks live in .githooks/ (not .husky/, which
is stale). .githooks/pre-push did run per-service tests — but with -PskipIntegration,
which excludes the Testcontainers *IntegrationTest classes. So integration tests were skipped
locally and only ran in CI on push-to-develop/PR→develop. This is how the Phase 4
7f761a31 PUT→POST flip left EventWorkflowControllerIntegrationTest sending PUT (→405)
undetected — it compiles fine (runtime routing mismatch) and no local gate executed it until
Phase 6 ran the full EMS suite. Fixed in Phase 6: -PskipIntegration removed from
.githooks/pre-push + docs/api/* mapped to owning services, so pre-push now runs the
integration suite for changed components (needs Docker).
Branch HEAD: e9dd536a. Commits this far (newest first): e9dd536a ($ref shared schemas
- partner orphan FE cleanup),
7f761a31(EMS lifecycle),fb44bc5d(CUMS removals),eb4d1fb6(deprecate-half, now superseded),af3cd875(Phase 3 stub, now superseded),a7802ef3(Phase 1),4bce02ef(Phase 0).
Shared-DTO consolidation (design + progress — 2026-06-28)
Goal: the ~7 hand DTOs returned by multiple controllers block per-controller implements-wiring.
Consolidate each to its generated twin (delete the hand DTO), then the consumers can be wired.
Strategy: mapper-centric, dependency-ordered (leaves first), ONE DTO per commit, each verified
(compile + affected integration tests + FE regen/type-check + smoke + Bruno), committed LOCALLY
(push gate is unusable in the agent runner — see PENDING PUSH above).
Dependency graph: SessionMaterial + SessionSpeaker + TimetableSlot → SessionResponse → TimetableResponse;
SpeakerPoolResponse, ContentSubmitResponse, SlidesOnlineSendResponse independent.
| Shared DTO | Status | Notes |
|---|---|---|
| SlidesOnlineSendResponse | ✅ DONE | generated (newsletter); STATUS_PENDING→StatusEnum.PENDING; service + SlidesOnline/RegistrantNotice ctrls + test; hand DTO deleted. 18/18. |
| ContentSubmitResponse | ✅ DONE | schema COPIED from dormant speakers-api → event-speakers-api (ADR-014); ContentSubmissionService + SpeakerStatus/SpeakerPortalContent + integ test (record accessors→getters); hand DTO deleted. 40/40. |
| SpeakerPoolResponse | ✅ DONE (2026-06-28, Option A) | Consolidated to the generated speakers.dto.generated.SpeakerPoolResponse (34 fields). Extracted mapper.SpeakerPoolMapper (@Component: toResponse/toResponse(pool,session)/toResponseWithContent/applyProposal) replacing the 3 static factories; injected into SpeakerPoolService (+5 call sites) + SpeakerStatusController:156. Both overlay paths re-typed (import-only): PrimarySpeakerResolver.applyOverlay + SpeakerPoolService.applySessionIdentityOverlay (setters byte-identical). EventController (3 endpoints) + SelfNominationController re-pointed (import/FQN). Trimmed 5 fields (FE-unread off the pool entry, grep-verified): isPublishable, contentStatus, materialCloudFrontUrl, remindersDisabled, materialFileName — which also let the material-enrichment block (+sessionMaterialsRepository dep + N-per-row query) be removed from the list path (3 dedicated SpeakerPoolServiceTest cases deleted; the remindersDisabled toggle endpoint/entity keep the field). Conversions: SpeakerWorkflowState→StatusEnum.fromValue(state.name()), String source→SourceEnum, 6× Instant→OffsetDateTime (…Z byte-identical), LocalDate unchanged. 🐛 DESIGN CORRECTION — status wire is UPPER_CASE, not lowercase. The design assumed lowercase_snake + .name().toLowerCase(); reality: the hand DTO emitted getStatus().name() (UPPER) and the ENTIRE FE compares uppercase literals ('CONTENT_SUBMITTED'…). DB stores lowercase but a JPA converter maps it; the wire was always uppercase. Authored the spec enum as the 8 UPPER_CASE states (dropped slot_assigned/confirmed/withdrew/overflow, added INVITED). FE speakerPool.types.ts: SpeakerPoolEntry/SpeakerWorkflowState repointed to the generated event-speakers-api type (was the dormant speakers-api). Verified: compile; SpeakerPoolServiceTest + PrimarySpeakerResolverTest + SpeakerStatusControllerIntegrationTest(24) + SelfNominationControllerIntegrationTest (39 green); EMS restart + live kanban smoke (BATbern57/73 — uppercase enum, 34 fields, dropped absent, overlays live); Bruno speaker-pool-api 78/78 (fixed 36-list isPublishable assert); FE type-check + full vitest 5334. Hand DTO deleted. ⏸️ Controller implements-wiring deferred (per consolidation precedent): SpeakerStatusController (8 ops, many other hand DTOs) needs a full re-tag + all-ops-documented pass; SelfNominationController (clean 1-op) is a small follow-up. The other two enum blocks in event-speakers-api (a username+status schema @ ~1535 [stale 11-value lowercase], SendInvitationResponse.status @ ~1699) are separate schemas left out of scope. |
SessionSpeakerResponse → SessionSpeaker |
✅ DONE | generated twin SessionSpeaker (sessions). Single ctor site = SessionUserService.enrichWithUserData (×2) → builds generated; conversions profilePictureUrl String→URI (pass user.getProfilePictureUrl() directly), domain SpeakerRole→SpeakerRoleEnum via fromValue(name()) (extracted toSpeakerRoleEnum helper), isConfirmed boolean→Boolean. Consumers re-typed: SessionResponse/CompanySessionResponse embed List<SessionSpeaker>, SessionService/GlobalSessionController/SessionSpeakerController/NewsletterEmailService + 2 tests. Wire byte-stable (all @JsonProperty identical, @JsonValue enum; additive companyLogoUrl:null). Test getter shift isConfirmed()→getIsConfirmed(), response enum asserts→SessionSpeaker.SpeakerRoleEnum.* (kept entity captured/sessionUser on domain SpeakerRole). Verified: compile + SessionUserServiceTest/NewsletterEmailServiceTest + SessionSpeaker/StructuralSession integ + EMS restart + live smoke (real BATbern1 speaker) + Bruno 14/14 + FE type-check. Hand DTO deleted. |
| SessionMaterialResponse | ✅ DONE | NO twin existed → authored a SessionMaterialResponse schema in event-sessions (NOT SessionMaterial as first planned — collides with domain entity domain.SessionMaterial in the toResponse mapper; *Response also matches ContentSubmit/SlidesOnline naming). 14 fields; cloudFrontUrl plain string, id string/uuid→UUID, only conversion createdAt/updatedAt Instant→OffsetDateTime via .atOffset(UTC) (byte-identical wire …Z, verified live). Single ctor site SessionMaterialsService.toResponse; consumers re-pointed import/FQN only — name unchanged (SessionResponse needed an explicit import — was same-package; SessionService, SessionMaterialsController, EventController FQN, + test). globalProperties models:'' generates the unreferenced schema. Verified: regen+compile main+test; SessionMaterialsServiceTest + Controller/SchemaValidation integ; FE regen+type-check; EMS restart + live smoke (BATbern36 material); Bruno 14/14. Hand DTO deleted. |
| SessionResponse | ✅ DONE | twin was allOf:[Session] and DROPPED materials/materialsCount → added them to the SessionResponse schema (inline allOf extension). Ride-along drift fixes (both REQUIRED — the stale enums would 500 on real data): Session.sessionType enum→string (DB has aperitif, absent from the enum — verified a live aperitif session now returns 200, would’ve been fromValue 500) and Session.materialsStatus enum→string (DB has PARTIAL, absent from the enum). Zero backend blast radius (no Java consumes the generated Session/its enums); FE widens enum→string (type-check clean). Mapper SessionMapper.toDto: timestamps Instant→OffsetDateTime via .atOffset(UTC) (wire …Z byte-identical, live-verified), sessionType/materialsCount/materialsStatus stay String/Integer (now match generated). SessionService.toSessionResponse builder type SessionResponseBuilder→Builder. Consumers re-pointed (import/FQN): TimetableResponse (new import — was same-package), TimetableService, StructuralSessionService, SessionController, EventController (FQN), + SessionMapperTest (4 timestamp asserts String→OffsetDateTime), Structural/Timetable test import/FQN (new SessionResponse() + setters intact). Verified: compile main+test; SessionMapper/Structural/Timetable unit + StructuralSession/SlotAssignment integ; FE regen+type-check; EMS restart + live smoke (full shape incl. aperitif-200); Bruno 14/14. Hand DTO deleted. |
| TimetableResponse (+TimetableSlot) | ✅ DONE (boundary-mapper, owner-chosen) | Not a delete-the-hand-DTO consolidation — investigation showed TimetableSlot is a dual-purpose internal scheduling/keying model, not a pure DTO: Instant arithmetic + a Map<Instant,Session> exact-match in TimetableService, ~25 arithmetic sites, 32 Type refs, and SlotReorderService consumes getTimetable().getSlots() internally for reorder logic. A full Instant→OffsetDateTime + Type→TypeEnum rewrite (~60 sites) was high-risk for working scheduling code with no current wiring payoff. Owner chose boundary-mapper: keep the internal hand TimetableSlot/TimetableResponse (Instant) untouched; getTimetable still returns the internal model (SlotReorder still works); a new TimetableMapper.toWire converts internal→generated (Instant→OffsetDateTime UTC, Type→TypeEnum by name) only at the controller boundary. Wired TimetableController → generated TimetableApi (re-tagged getEventTimetable Sessions→Timetable so the 1-op interface is 1:1; class @RequestMapping /api/v1/events/{eventCode}→/api/v1, @PreAuthorize kept on the override). SlotAssignmentController.assignSessionToSlot maps at its boundary too. Internal types + their tests (TimetableServiceTest, SlotReorderServiceTest) untouched. Verified: compile main+test; new TimetableMapperTest + internal-service unit + SlotAssignment integ; FE regen (no change — re-tag only) + type-check; EMS restart + live smoke (GET /timetable 200, correct slot shape incl. …Z timestamps); Bruno 14/14. EMS contract-first +1 (TimetableController). |
⚠️ SpeakerPoolResponse — DESIGN RESOLVED (Winston/Nissim, 2026-06-28). The generated
SpeakerPoolResponsespec schema has 15 fields; the deployed hand DTO has 39. Owner question answered: yes, keep ~35 of them — this is NOT a dead-field grab-bag. Field-level FE consumption analysis (every reader traced acrossweb-frontend/src, non-test) shows ~35 of 39 fields are live, and the “fatness” is structural, not accidental: the organizer kanban board (SpeakerStatusLanes.tsx) is one LIST endpoint whose cards expand into an inline drawer that reads off the already-loaded board entry (DetailsTabPanel,ContentSubmissionSubView,QualityReviewSubView,PromoteSpeaker*all readspeaker.*off the list item — no per-card refetch). That is a legitimate “collection + inline detail” read model. The 24 hand-DTO-only fields have exactly one consumer — the FE’s hand-writtenSpeakerPoolEntry(web-frontend/src/types/speakerPool.types.ts); the Swift watch app carries only the 15-field generated model and never reads it; no other backend/Bruno consumer.Field verdicts (verified, not from the subagent’s first pass — it under-counted by 6):
- LIVE (~35) — core/identity (id, eventId, speakerName, company, companyDisplayName, expertise, assignedOrganizerId, status, notes, createdAt, updatedAt, username), session (sessionId, sessionSlug), brainstorm provenance (source, proposedByUsername, proposedSessionTitle, proposedAbstract), invitation (email, invitedAt, responseDeadline, contentDeadline), response-prefs (acceptedAt, declinedAt, declineReason, preferredTimeSlot, travelRequirements, technicalRequirements, initialPresentationTitle, preferenceComments — all read in
SpeakerStatusLanes.tsx:1043-1091), content/derived (contentSubmittedAt, submittedTitle, submittedAbstract, isSlotAssigned).- DEAD in FE → TRIM (4) —
isPublishable(FE derives fromisSlotAssigned+statusingetPrimaryAction.ts:160; backend still computes per ADR-009 §0.5),contentStatus(derived server-side, never read off the pool entry),materialCloudFrontUrl(pool copy unread),remindersDisabled(no reader).- BORDERLINE → verify then likely trim (1) —
materialFileName: read on the speaker-portal’s ownSpeakerEventContenttype and onQualityReviewSubView’scontent.*, but not off the pool entry.⚠️ Ride-along correctness fix (do NOT skip): the generated
SpeakerPoolResponse.StatusEnumstill carries 11 status values including the four ADR-009 §0.1 DELETED (SLOT_ASSIGNED,CONFIRMED,WITHDREW,OVERFLOW) +INVITED. The spec is stale vs the 8-state ADR; the FE already moved to the 8-state union (speakerPool.types.ts:9-13, “legacy widenings dropped per Story 11.E.4”). Author the consolidated schema with the 8 ADR-009 states — do not copy the stale 11 forward.Decision: Option A (consolidate-whole + trim dead), NOT a split. A board-summary / item-detail split (Option B — lean list +
GET …/speakers/{id}detail fetched on drawer-open) is the “cleaner REST” design and a real improvement, but it is orthogonal to and heavier than the Phase-7 consolidation: it forces an FE data-flow change (drawer must fetch-on-open instead of reading the loaded entry) across 4 sub-views, a new endpoint, all under staging=prod. Bundling it would make the consolidation commit un-reviewable. → File Option B as its own FE-coordinated follow-up story; do Option A now to unblock per-controller wiring.▶ Option A mechanics (ready to implement):
- Spec — in
docs/api/event-speakers-api.openapi.yml, expand theSpeakerPoolResponseschema to the ~35 live fields (dropisPublishable,contentStatus,materialCloudFrontUrl,remindersDisabled; confirmmaterialFileNameunused off the entry then drop). Setstatusto the 8-state enum (identified, contacted, ready, invited, accepted, content_submitted, quality_reviewed, declined) and keepsourceas the 2-value enum. Mark only the 6 always-present fields required (id, eventId, speakerName, status, createdAt, updatedAt); everything else nullable. Regen FE types (npm run generate:api-types) →event-speakers-api.types.ts, then repointweb-frontend/src/types/speakerPool.types.tsSpeakerPoolEntryto the generatedcomponents['schemas'] ['SpeakerPoolResponse'](it already imports the workflow-state from generated — collapse the hand-written interface onto the generated type, keepingSpeakerPoolUI extendsfor the resolvedassignedOrganizerName).- Mapper extraction — the mapping is embedded as static
fromEntity(SpeakerPool)/fromEntity(SpeakerPool, Session)/fromEntityWithContent(SpeakerPool, Session, SessionContentVersion)
- instance
applyProposal(SessionProposal)on the hand DTO, PLUS two identity-overlay paths that must both be preserved:PrimarySpeakerResolver.applyOverlay(response, pool)(single-entry paths) andSpeakerPoolService.applySessionIdentityOverlay(response, primarySessionUser, userResponse)(the batch list path,SpeakerPoolService.java:356-379). Extract the 3 factories +applyProposalinto aSpeakerPoolMapper(@Component, returns the generated DTO via itsbuilder()); leave the two overlay methods where they are but re-type them to mutate the generated DTO (their setters are byte-identical).- Type conversions (hand → generated):
status:SpeakerWorkflowState(Java enum) →SpeakerPoolResponse.StatusEnumviaStatusEnum.fromValue(state.name().toLowerCase())(wire values are lowercase_snake — see §Enum flow).source:String(‘organizer_added’/‘self_nomination’) →SourceEnum.fromValue(source)(null-safe).- 6×
Instant→OffsetDateTime: createdAt, updatedAt, invitedAt, acceptedAt, declinedAt, contentSubmittedAt →instant.atOffset(ZoneOffset.UTC)(null-guard each).- 2×
LocalDatestaysLocalDate: responseDeadline, contentDeadline (generated emitsLocalDate).- Derived flags
isSlotAssigned(and the trimmedisPublishable) computed exactly as today (ADR-009 §0.5:session.startTime != null;isPublishable = QUALITY_REVIEWED ∧ isSlotAssigned).- Call sites (5 builders) —
SpeakerStatusController:156(promote →fromEntity+applyOverlay),SelfNominationController(self-nominate →fromEntity+applyOverlay+applyProposal),SpeakerPoolService:136/263/459/509(add/self-nominate/patch/× →fromEntity+ overlay) and:364(the list,fromEntityWithContent+applyProposal+applySessionIdentityOverlay). After the mapper swap these callspeakerPoolMapper.toResponse(...); record accessors → generated getters/builders.EventControlleronly imports/embeds — no SpeakerPoolResponse build there (itsenrichWith*are forEventResponse).- Wire
SpeakerStatusController+SelfNominationControllerimplements <Ctrl>Apionce the DTO is the generated type (the actual Phase-7 unblock this consolidation exists to enable).- Verify (per §Strategy): compile;
SpeakerPoolServiceTest+SpeakerStatusControllerIntegrationTest
SelfNominationControllerIntegrationTest; EMS restart + live gateway smoke of the kanban LIST (GET /events/{code}/speakers, real BATbern event — read-only, no email) confirming all ~35 fields serialize with the 8-state enum; full Bruno 14/14; FEnpm run generate:api-types+ type-check + full vitest (theSpeakerStatusLanes/drawer suites exercise the trimmed fields). Commit LOCALLY (push gate unusable in agent runner — see PENDING PUSH).Follow-up (separate story): Option B board/detail split — lean
SpeakerPoolSummaryfor the board +GET /events/{eventCode}/speakers/{id}→SpeakerPoolDetail; drawer fetches on open. Real ADR-013 collection/item improvement + smaller list payload, but needs the FE drawer-fetch refactor; do not couple to the consolidation above.
| Phase | Status | Notes |
|---|---|---|
| 0 Shared-kernel | ✅ DONE | Deleted dead duplicate utils.ErrorResponse + ErrorHandlingUtils (+test); added docs/api/_shared.openapi.yml; fixed 04-api-core.md. |
| 1 Dead-spec removal | ✅ DONE | events-api stale /topics* block + partners-api orphan voting removed (spec). Frontend completion (in e9dd536a): removed the dead orphan-voting client getPartnerVotes/usePartnerVotes (+tests) that called the never-implemented 404. |
| 2 Document live routes / re-enable generators | ❌ WON’T DO | Owner decision (2026-06-26), now ADR-014: speaker/attendee services stay dormant (consolidating toward an EMS/CUMS few-service backend), so do NOT re-enable their generators. The route-documentation bit is dropped too (low value, codegen risk on the wired partners-api). Phase closed. |
| 3 Convention conformance | ✅ DONE | ✅ Shared schema solution DONE (e9dd536a): shared $ref _shared.openapi.yml for both generators. ✅ List-query collapse DONE: listUsers (CUMS) role/company/sortBy/sortDir → filter/sort; 5 live callers migrated (email-forwarder Lambda ×2 + partner getUsersByRole + EMS getOrganizerUsernames/getPartnerUsernames — the latter 3 were undocumented in the original plan) using fully-encoded URIs. listNewsletterSubscribers (EMS) sortBy+sortDir → sort (typed status/search kept). Both controllers now use shared SortParser. 🐛 Bug fixed: users-list sort was a no-op — all 8 paginated UserRepository queries hardcoded ORDER BY u.lastName ASC, overriding the Pageable sort; removed so server-side sort actually works (+ fixed a Set.of(...).contains(null) NPE it exposed). 🐛 Bug fixed (2026-06-27, re-diagnosed): NOT a missing predicate. The filter parser + UserRepository correctly apply the singular role key (proven by UserControllerIntegrationTest.should_filterByRole_when_roleFilterProvided, which passes with filter={"role":"ORGANIZER"}, and used by the FE userManagementApi + the email-forwarder Lambda). The real failure: Bruno users-api 15/19/20 still sent the removed top-level ?role= param, so listUsers ignored it and returned role-unfiltered results. The plan’s {"roles":…} (plural) was a misdiagnosis — that plural key is the response projection field, not the filter key. Fixed by migrating the 3 Bruno tests to ?filter={"role":…}. See §Phase 3. |
| 4 Mutation-model fixes | ✅ DONE | ✅ CUMS removals DONE (fb44bc5d). ✅ EMS lifecycle DONE (7f761a31). ✅ Session PUT removed (ed59fa0b): dead full-replace PUT twin (no caller; field-nulling footgun) deleted + dead UpdateSessionRequest DTO/SessionMapper.applyUpdateRequest; spec now documents the live patch: (PatchSessionRequest). ✅ Partner deactivation DONE (d6fdc5b0): dropped dead isActive from UpdatePartnerRequest (backend ignored it; FE toggle unwired) — DELETE is the canonical soft-deactivate. ✅ Registration-cancel resolved — NOT a merge (the two are distinct flows). Investigated the legacy JWT /cancel: confirmed dead (no email template renders cancellationUrl; all use the Story-10.12 /deregister UUID flow) and removed it end-to-end — endpoint, generate/validateCancellationToken, the dead cancellationToken/cancellationUrl threaded through the registration-confirmation email path, spec path, FE CancelRegistrationPage + route + eventApiClient.cancelRegistration, and all tests. The shared RegistrationService.cancelRegistration(Registration) (used by /deregister + waitlist) stays. ✅ Spec polish DONE: named the inline object request bodies as AssignSpeakerToSessionRequest/DeclineSpeakerRequest/PatchNewsletterSubscriptionRequest (batchImportSessions already used a named items schema). Reconciled UpdateEventSlotConfigurationRequest ⟷ UpdateEventAgendaConfigRequest by documenting the distinction (cross-referenced descriptions: event-type-level defaults vs per-event copy-on-edit, differing required-field strictness) rather than a structural allOf merge — they are genuinely distinct contracts on different endpoints, and UpdateEventAgendaConfigRequest is a hand-written backend DTO, so an allOf merge would risk a live feature’s generated types for no real gain. Phase 4 COMPLETE. |
| 5 Partner consolidation 5→2 | ✅ DONE | Folded partner-notes-api + partner-analytics-api + partner-topics-api into the generator-wired partners-api.openapi.yml (5→2; partner-meetings-api kept separate + brought to parity: shared $ref ErrorResponse, documented GET /partner-meetings/{id}/rsvps + the internal RSVP callback, bounded-list note). /attendees/topics relocated under a dedicated Attendee Topics tag (documented alias). Spec made truthful: added the live-but-undocumented PATCH/DELETE /partners/topics/{topicId} (updateTopic/deleteTopic) and eventTitle on AttendanceSummaryRecord; clarified getPartnerStatistics (portfolio summary) vs analytics/dashboard (attendance) boundary; normalized tags + relative /api/v1 server + global bearerAuth. Controllers rewired to implement the generated interfaces: PartnerNoteController→PartnerNotesApi, PartnerAnalyticsController→PartnerAnalyticsApi (export now returns Resource), TopicController→PartnerTopicsApi (role resolved from SecurityContextHolder, no injected Authentication), AttendeeTopicController→AttendeeTopicsApi. Hand-written record/Lombok DTOs (PartnerNoteDTO, CreateNoteRequest, UpdateNoteRequest, TopicDTO, TopicSuggestionRequest, TopicStatusUpdateRequest, PartnerDashboardDTO) deleted — generated DTOs thread through the service layer (Instant→OffsetDateTime, String→inner enums). FE: deleted partner-notes/partner-topics generate scripts + stale .types.ts, repointed partnerNotesApi.ts to partner-api.types. Full BE partner-coordination suite + full FE vitest (5334) + FE type-check green. |
| 6 events-api decomposition | ✅ DONE | Carved the 10.3k-line events-api.openapi.yml (94 paths / 122 ops / 131 schemas / 16 tags) into 9 per-domain specs — events-core + event-{sessions,speakers,registrations,newsletter,media,ai,analytics,watch}-api — driven by a deterministic carve script (path→domain map + computed schema ownership; report-only validated first). Paths preserved verbatim (122/122 ops, 0 dangling refs, no dup ops). Only cross-spec coupling is core → sessions (Event embeds List<Session>); every other spec depends only on shared-kernel. Full per-domain Java + TS packages (owner’s explicit choice): 9 openApiGenerate<Domain> Gradle tasks (each → ch.batbern.events.<domain>.{api,dto}.generated), 9 FE event*-api.types.ts. 165 Java FQN re-points across 102 files (dto.generated.X → <domain>.dto.generated.X) + 4 controller interface re-points (EventTypes→core, SpeakerOutreach→speakers, AiPrompts→ai, EmailTemplates→newsletter). 54 FE files re-pointed (51 single-domain swap + 3 multi-domain aliased imports). 🐛 openapi-generator bug #17647 worked around: schemaMappings to a cross-package type emits illegal List<@Valid <FQN>>; switched core’s Session/SessionSpeaker to importMappings (import + simple name) + a doLast that deletes the dead duplicate copy. 2 defined-but-unreachable schemas (Speaker, RegistrationAdminResponse) removed as dead-code cleanup (follow-up): Speaker was an ADR-004-violating User-field duplicate with only a dead SpeakerUI alias; RegistrationAdminResponse had no path/code use. Updated: security-scan matrix (1→9 entries), BATbern-watch generate-types.sh (loops 9 specs), FE generated README. Clean Java compile (main+test) + FE type-check (0 errors) + EMS suite + FE vitest green. Single PR. |
| 7 Contract-first completion | ✅ COMPLETE (EMS + CUMS + Partner; branch api-consolidation-phase7) |
Every wireable controller across all three services is contract-first. EMS: EventController CRUD + EventReportingApi; BulkOperationsApi removed (dead); GlobalSessionController wired (new Company Sessions op — it was NOT dead). CUMS: all production controllers, incl. the formerly-undocumented UserController 5 orphan endpoints, LogoController (7 ops → companies-api Logos tag), PublicOrganizerController; dead ActivityHistoryApi op removed. Partner: meetings (new partner-meetings-api generator). Generator on 7.14.0 (9a). All hand DTOs shadowing generated schemas consolidated; ad-hoc Map/?/byte[] returns typed (byte[] → Resource); 409s via @ExceptionHandler. Only intentional non-wires remain: DevEmail (@Profile("local")), TestFixtureCleanup (admin test util, outside the public contract), Watch* (Phase 8 external app). |
| 8 Domain-boundary corrections | 🟡 PARTIAL | ✅ /public/settings/features relocated off AiAssistController → new event-app-settings-api spec + PublicSettingsController (path-preserving, zero client impact). ⏭️ /attendee-portal/dashboard → attendee domain still deferred (service dormant). ⏭️ Watch path normalization owner-skipped (generated WatchApi unimplemented + iOS app vendors its own spec → latent/harmless). See §Phase 8. |
| 9a Generator upgrade | ✅ DONE (branch api-consolidation-phase7) |
Upgraded openapi-generator 7.2.0 → 7.14.0 (both pins: settings.gradle + root build.gradle). #17647 is fixed → removed the EMS core→sessions workaround (importMappings+doLast); Session/SessionSpeaker now use plain schemaMappings and Event.java emits the legal pkg.@Valid Session. Did this before Phase 7 so controllers wire against the final interface shape once. Churn was tiny: param type Optional<X>→@Nullable X (fixed EmailTemplateController); enum prefix no longer stripped, WELCOME→AFTER_WELCOME (wire values unchanged; fixed 4 refs in EventTeaserImageServiceTest); Java-client models-only now injects an unsatisfied ApiClient import → switched partner’s generateCompanyClientDtos/generateUserClientDtos to the spring generator (consistent w/ EMS). 🐛 Wire-behaviour fix: 7.14 default-initialises collection fields (= new ArrayList<>()) instead of leaving them null, so an unset list serialised as [] instead of being absent — broke the ?include= sparse-fieldset contract (TopicControllerIntegrationTest saw usageHistory: [] when not embedded). Fixed by adding containerDefaultToNull: 'true' to every generator configOptions block (CUMS ×2, EMS ×11, partner ×3) → restores pre-7.14 null-default. All other changes additive (per-DTO inner Builder, @Nullable field annots). Whole Java monorepo compiles main+test; FE unaffected (its types come from openapi-typescript, not this plugin). See §Phase 9. |
| 9 Tooling & spec hygiene (rest) | ✅ DONE | Generator upgrade done (→ 9a). Declared top-level tags in the 2 specs missing them (event-media-api, partner-meetings-api; + AI Prompts in event-ai-api); fixed the stale workflowService.ts PUT→POST comment. See §Phase 9. |
EventController CRUD wiring (planned 2026-06-29)
The last EMS wiring gap. EventController (2,573 lines, services/event-management-service/.../controller/EventController.java)
owns the hand-built EventResponse and stays unwired. This section is the actionable, owner-approved
(Nissim, 2026-06-29: embed-on-detail, lean list, no FE lazy-load) plan to make it contract-first and aligned
with the consolidated events-* specs. It is a single backend story — there is no FE lazy-load story.
✅ DONE (2026-06-29) —
EventControllercore CRUD wired toEventsApi(7) +EventActionsApi.publishEvent(1). Class flipped@RequestMapping("/api/v1/events")→"/api/v1",implements EventsApi, EventActionsApi; the 8 ops are bare-param overrides returning the generatedEvent(lean list)/EventDetail(materials on detail)/ListEvents200Responsevia the pureEventGeneratedMapper; the ~16 hand-rolled registration/workflow/topic/reporting ops were path-absolutized to/events/…and stay hand-rolled. Mutation request-side handled by generated→hand adapters (toHandCreate/Update/Patch: OffsetDateTime→ISO String, generated EventType→String/getValue(), generated→sharedEventWorkflowState, generated→domainQnaOpenTrigger) so the proven bodies stayed byte-identical.@JsonInclude(NON_NULL)added toEvent+EventDetail(viax-class-extra-annotation) to restore the hand DTO’s null-omission (list went from 43 keys w/ 22 nulls → 21 keys, 0 nulls). Spec-truth fixes surfaced during wiring:Event+9 live fields (6 metrics +topicSelectionNote/createdBy/updatedBy, non-nullable optional);EventDetail.sessions Session→SessionResponse;BatchUpdateResponsetruthful;PatchEventRequest+topicCode/topicSelectionNote;listEvents+includeArchived,pagedefault 0→1 (parseParams rejects ≤0),limitmaximum:100dropped (deployed clamps, not rejects);CreateEventRequest.required→[title,date,eventType],UpdateEventRequest.required→[title,date] (deployed only@NotBlanktitle/date +@NotNulleventType; venue/deadline/ organizer/eventNumber optional). FE: regen events types +validateRegistrationDeadline/validateVenueCapacityguard the now-optional fields;EventDetailUIre-declarations unchanged (fields non-nullable → match). Verified (local dev):EventControllerIntegrationTest103/0; full Bruno 14/14 (twice); Playwright event-surface 26/26 (archive detail/ sessions/speakers/not-found, registration, event-type, topic-selection); live gateway smoke (lean list 0-null, detail w/ materials + rich SessionSpeaker,@Pattern→400, BATbern888→404, current→200, limit 500→clamp 100); FE type-check clean.@Patternfixture sweep:BATbern888(404 probes),BATbern9999(CapTest). Thearchive-filteringtopic→URL test is a pre-existing local-dev flake (identical-steps test 43 flakes 2/3; documented divergence) — not a regression. Follow-ups resolved 2026-06-29:
- ✅
BulkOperationsApi.batchUpdateEventsREMOVED (not wired). Repo-wide sweep proved it dead: no FE caller (the FEapiClient.patch('/events/{eventCode}')is singlepatchEvent), no backend caller — only a self-test exercised it. Deleted the events-corePATCH /eventsop +BatchUpdateRequest/BatchUpdateResponseschemas + the events-coreBulk Operationstag,EventController.batchUpdateEvents+ the handBatchUpdateRequestDTO, the 2 integration tests, andbruno-tests/events-crud-api/19-batch-update.bru. Verified: authedPATCH /events→405 (handler gone), GET/POST/ single-PATCH intact; EventControllerIntegrationTest green; Bruno events-crud-api 32/32. (event-sessionskeeps its ownBulk Operationstag forbatchImportSessions— untouched. The watch-app’s stale monolith spec copy is separate.)- ✅
EventAnalyticsschema made truthful (commit8b269b1a): the fictional flat fields dropped, replaced with the real{eventCode, timeframe?, metrics{registrations?,attendance?,engagement?}}shape. getEventAnalytics is now wireable (unblocked); only theEventReportingApicontroller wire remains (getEventAnalytics Map→typed + getAttendanceSummary hand→generatedAttendanceSummaryDTOconsolidation) — a clean follow-up. EMS = 43 wired; noBulkOperationsApi. Authoritative full EMS suite + push pending on an interactive terminal (agent runner can’t run the pre-push gate).▶ (historical) contract foundation + boundary mapper landed first (EMS
compileJavagreen, additive):
- Spec (
events-core):Eventgained the 9 live-but-undocumented fields (6 metrics +topicSelectionNote/createdBy/updatedBy— verified read by the organizer cockpitMetricTiles/cockpitCards);EventDetail.sessionsSession → SessionResponse(materials on detail);BatchUpdateResponsemade truthful (typedsuccessful[]/failed[]/summary);PatchEventRequestgainedtopicCode/topicSelectionNote.build.gradle:SessionResponseadded to the events-core generator mappings (uses the sessions-package class).mapper/EventGeneratedMapper.java(NEW,@Component, pure, compiles): boundary mapperEventResponse → Event(leanSession, no materials) /EventResponse → EventDetail(richSessionResponse+ materials). Tolerates both the list-path (nested-Mapspeakers,Instanttimes) and detail-path (typedSessionSpeaker/SessionMaterialResponse,OffsetDateTime) shapes. Safe enum coercions (drift → null, not 500). Chosen over “delete EventResponse” — the enrichment (batch portrait join,?include=sparse-fieldset) stays byte-identical (TimetableResponse precedent).Scope refined during implementation — two ops DEFERRED (each a clean small follow-up, both surfaced as spec-truth gaps):
BulkOperationsApi.batchUpdateEvents— generated body is a{updates:[…]}wrapper but the deployed endpoint takes a bareList<BatchUpdateRequest>; needs the spec request body made a bare array + hand→generatedBatchUpdateRequestconsolidation. No FE consumer. (Response schema already made truthful above.) Stays hand-rolled (@PatchMapping("/events")).EventReportingApi(getEventAnalytics + getAttendanceSummary) —getEventAnalyticsis bound to a fictionalEventAnalyticsschema (the service returns a dynamic{timeframe, metrics}Map, nothing like the schema); needs its own spec-truth pass, andgetAttendanceSummary(already typed) can’t be wired alone (shared interface). Both stay hand-rolled.Remaining wiring (enumerated, no unknowns) —
EventsApi(7) +EventActionsApi.publishEvent(1):
- Flip class
@RequestMapping("/api/v1/events") → "/api/v1";implements EventsApi, EventActionsApi.- Override the 8 ops: drop
@*Mapping, rename to operationIds, bare params, returneventGeneratedMapper.toEvent(...)/.toEventDetail(...)/ListEvents200Response.getEvent/getCurrentEvent→EventDetail;listEvents→ListEvents200Response; create/update/patch/publish →Event;deleteEvent→Void.- Mutation request-side surgery (create/update/patch): swap hand
ch.batbern.events.dto.{Create,Update,Patch}EventRequest→ generatedcore.dto.generated.*; the bodies need conversions —getDate()/getRegistrationDeadline()/getPublishedAt()are nowOffsetDateTime(wasStringviaparseDate) →.toInstant();getEventType()/getWorkflowState()are now enums →.getValue();applyPatchUpdatesretyped to the generatedPatchEventRequest.- Absolutize the ~15 hand-rolled paths (registration/workflow/topic/enroll + the 2 deferred reporting ops + deferred batchUpdate): prepend
/events(class is now/api/v1).@Patternfixture sweep (BATbern888); fixEventControllerIntegrationTest; FE regen + drop the redundantSessionUI.materialsaugmentation.- Verify: full EMS suite + live smoke + Bruno + Playwright + push on an interactive terminal (the agent runner can’t run the authoritative full suite / pre-push gate).
Corrected findings (re-verified 2026-06-29 — supersede the 2026-06-28 blocker note)
The 2026-06-28 deferral assumed a fat, irreducible embedded session/speaker shape. A fresh grep of the live specs + every FE reader narrowed it sharply:
Field the lean Session drops vs. the hand session-map |
FE reads it? | Verdict |
|---|---|---|
speaker company/companyDisplayName/profilePictureUrl/companyLogoUrl |
yes — but already on generated SessionSpeaker (event-sessions-api.openapi.yml:1943-1972) |
✅ non-issue (wire-identical) |
speaker bio |
never read off sessions (grep .bio → only User views + import); backend sets it null in the list path anyway |
✅ non-issue |
session id (UUID) |
never read in components/public/Event or components/organizer/EventManagement (matches the SlotAssignment finding) |
✅ non-issue |
materialsStatus |
on lean Session already (event-sessions-api.openapi.yml:1904) |
✅ non-issue |
materials (array) + materialsCount |
materials IS read — SessionEditModal.tsx:221, SessionCards.tsx:284, EventProgram.tsx:280; materialsCount only typed, not rendered |
⚠️ the one real gap |
The hand-map speaker fields (EventController.java:472-497) are a 1:1 superset-free match for the generated
SessionSpeaker properties — confirming the speaker embed is already representable by the generated SessionSpeaker.
The rest of EventResponse was already live-verified wire-compatible on 2026-06-28: EventType/EventWorkflowState
enums serialise to the deployed strings, the topic/venue Maps already emit the typed EventTopic/Venue
shape, Instant→OffsetDateTime is …Z-identical.
The design decision (resolved 2026-06-29) — embed on detail, lean list
The owner’s materials insight settled it: there are two distinct “materials” needs, and they’re served by two different fields:
| Need | Consumers | Served by |
|---|---|---|
| “Does this session have materials?” (badge / yes-no) | ~all session views (cards, lists, agenda) | materialsStatus (NONE/PARTIAL/COMPLETE) — already on the lean Session (event-sessions-api.openapi.yml:1904). Free, no array. |
| The actual material files (filename/type/download) | only 2 views — public archive program (SessionCards/EventProgram, on HomePage) + organizer SessionEditModal |
the materials array |
Both file-needing views load a single-event detail (getEvent) — the archive via HomePage’s
getEvent(expand: sessions), the modal via the organizer event-management detail. GET /api/v1/events/* is
permitAll (EMS SecurityConfig.java:86), so the anonymous archive reads it fine. ⇒ if the materials array
rides along on the single-event EventDetail (and not on the list), both views get it in the call they
already make — zero extra fetches, zero FE refactor. Lazy-loading would only add N calls on the archive for
no benefit. Rejected: the FE lazy-load approach (and the speaker-detail lazy-load, which was moot from the start).
Single story — wire EventController core CRUD → EventsApi
Goal: EventController implements EventsApi, returning the generated Event (list items / mutations) and
EventDetail (single getEvent/getCurrentEvent); delete the hand EventResponse. List sessions stay lean;
detail sessions carry materials + rich speakers.
Scope = the 8 core CRUD ops only (listEvents, createEvent, getEvent, getCurrentEvent, updateEvent,
patchEvent, deleteEvent, batchUpdateEvents) + the 2 EventReportingApi + 1 BulkOperationsApi ops. The ~13
registration ops + workflow/topic/publish ops on this controller share tags across controllers and stay
hand-rolled for now — leave them on EventController alongside implements EventsApi (a class may serve generated
- hand routes); a
RegistrationControllerextraction (theEventSpeakerPoolControllerprecedent) is an optional follow-up. Prefer leaving-in-place to keep this story tight.
Spec changes (docs/api/events-core-api.openapi.yml):
Event.sessions(list) → keep leanSession(line ~2407). CarriesmaterialsStatusfor the yes/no badge; no array. List payload stays small. ⚠️ Verify no LIST consumer reads thematerialsarray (grep on 2026-06-29 foundsession.materialsread only in the 2 detail views —SessionEditModal,SessionCards/EventProgram— so going lean on the list is safe; re-confirm at implementation).EventDetail.sessions→ change leanSession→SessionResponse(currently line ~2418 refsSession).SessionResponse=Session+materials+materialsCount(event-sessions-api.openapi.yml:2006-2024), with speakers already the richSessionSpeaker. This is the only sessions-shape spec change. (SessionResponse ⊇ Session, the SlotAssignment precedent — wire-compatible, fixes the ADR-013 raw-Mapleak.)- BulkOperationsApi.batchUpdateEvents — make
BatchUpdateResponsetruthful to the deployed shape (EventController.java:1207-1269):{ successful: [{eventCode, status}], failed: [{eventCode, error}], summary: {total, successful, failed} }. Author the nested item schemas; regen. - EventReportingApi.getEventAnalytics — type the freeform
Map(EventController.java:1675-1696) to a namedEventAnalyticsschema (superset +@JsonInclude(NON_NULL)for the metric-dependent fields).getAttendanceSummaryalready returns the typedAttendanceSummaryDTO(:2465) — just swap hand→generated DTO.
Backend changes:
EventController implements EventsApi(+EventReportingApi,BulkOperationsApi); class@RequestMapping/api/v1/events→/api/v1per the wiring convention; bare override params; methods renamed to operationIds.- Replace the hand
EventResponsewith the generatedEvent(list items / mutations) /EventDetail(getEvent/getCurrentEvent). Map raw entity → generated via a mapper. For detail sessions, reusesessionService.toSessionResponse(already emitsSessionResponsewith materials +SessionSpeaker— the SlotAssignment/SessionController precedent) instead of the handexpandSessionsMap (EventController.java:505-690). For list sessions, emit leanSessionvia the pureSessionMapper.toDto(no enrichment / no N+1, aslistSessionsalready does) — i.e. retire thebuildSessionMapBatchmaterials/speaker Map block (EventController.java:428-498). batchUpdateEvents→ typedBatchUpdateResponse(build the lists/summary as today, via the generated builder).getEventAnalytics→ typedEventAnalytics;getAttendanceSummary→ generatedAttendanceSummaryDTO.- Delete the hand
EventResponseDTO once all call sites use the generated types; consolidate any other hand DTOs shadowing generated schemas. @Patternfixture trap: enabling the generatedeventCode ^BATbern[0-9]+$validation turns malformed not-found probes into 400 — sweep the EMS tests for non-conforming eventCodes (theBATbern888fix pattern used throughout Phase 7) before running the suite.
FE changes (minimal — no lazy-load):
npm run generate:api-types:events→ the generatedEventDetail.sessionsnow exposesmaterials(viaSessionResponse). The 2 views already readsession.materials, so they keep working.web-frontend/src/types/event.types.ts— theSessionUI.materials?augmentation likely becomes redundant once the generatedEventDetailsession type carriesmaterials; collapse it onto the generated type (keepSessionUIonly for genuinely FE-local fields). Re-confirm list-view consumers don’t expectmaterials(they shouldn’t — list is lean by design).
Verify (local dev): EventControllerIntegrationTest + the registration/analytics integration tests; full EMS
suite (cross-controller-risky — shared @Pattern + the central controller — run it locally, not just the targeted
slice); EMS restart + live gateway smoke (GET /events list → lean Session.sessions with materialsStatus but no
materials array; GET /events/{code} → EventDetail with SessionResponse sessions carrying materials + rich
SessionSpeaker; batchUpdate typed shape; analytics typed); npm run generate:api-types:events + commit the
regenerated events-core/event-sessions FE types + FE type-check + targeted vitest (SessionEditModal,
SessionCards, EventProgram); full Bruno 14/14; Playwright organizer event-management + public archive (materials
still render). Commit; push from an interactive terminal (the .githooks/pre-push full-integration gate is killed by
the agent-runner wall-clock — see PENDING PUSH).
After this: EMS is 100% contract-first
Remaining EMS non-wires are intentional: Watch* (Phase 8), DevEmail/TestFixtureCleanup (dev/test-only, skip).
GlobalSessionController is now wired (2026-06-29 — it was NOT an orphan). With EventController + GlobalSession wired,
every wireable EMS controller is contract-first.
Shared-schema standard (decided + verified 2026-06-26 — use for ALL specs)
One canonical definition in docs/api/_shared.openapi.yml (full properties +
x-java-type), $ref’d by every spec:
PaginationMetadata:
$ref: './_shared.openapi.yml#/components/schemas/PaginationMetadata'
Why it works for both generators (proven on companies-api against both):
openapi-typescript7.x resolves the external$ref(built-in Redocly bundling) → full TS type.openapi-generator(Gradle) resolves the ref AND maps by name via the existingschemaMappings/importMappings→ shared-kernel class, no duplicate DTO.- ❌ Do NOT use a body-less
x-java-typestub (the abandoned Phase 3 approach): it’s fine for Java butopenapi-typescriptemitsRecord<string, never>(empty type) → breaks the FE.
Latent landmines still to fix (peripheral specs, optional consistency pass):
events-api/partners-api were already done in e9dd536a. But speakers-api,
attendees-api, partner-meetings/notes/topics/analytics, file-upload, auth-endpoints
still have bespoke/empty ErrorResponse (and some Pagination) shapes — convert them to
$ref _shared too when convenient (low risk; most aren’t generator-wired).
Versioning rule for this repo (decided 2026-06-26)
Single-user system, we own backend + frontend + the email-forwarder Lambda. So: remove
redundant endpoints outright and adapt all callers in the same commit — NO deprecated:
period. (This supersedes the deprecate-then-remove guidance in the “Versioning & deprecation
approach” section below; that section is kept for historical context only.)
▶ RESUME GUIDE — per-change workflow (Phases 3–6 are ✅ DONE; Phase 7 EMS ✅ DONE)
Current status lives in the ▶ STATUS block near the top (Phase 7 EMS complete; remaining = EventController CRUD / Phase 8 / Phase 9). The Phase 3–6 sub-sections below are historical (all landed). The per-change workflow + env gotchas here remain the canonical recipe for any further spec/controller work.
Workflow per change: edit spec → npm run generate:api-types (FE) + Gradle regen/compile
(BE) → fix tests → restart the touched service (make dev-native-restart-service SERVICE=…)
→ Bruno + Playwright @smoke → commit → push. .githooks/pre-push now runs the FULL
integration suite (incl. Testcontainers) for changed components — Docker must be up, expect
multi-minute pushes on backend/spec changes; bypass with git push --no-verify only in an
emergency.
Env gotchas: macOS has no timeout (use the Bash tool’s own param); dev Postgres is the
existing batbern-dev-postgres container (docker start it); refresh tokens with
./scripts/auth/refresh-token.sh staging [role] </dev/null; run the FULL FE suite
(npx vitest run) before pushing — a targeted run missed partnerApi.test.ts once and the
pre-push hook rejected the push.
Phase 3 — list-query collapse ✅ DONE (one open bug, see below)
Landed (see status table above): listUsers + listNewsletterSubscribers collapsed to filter/sort,
5 live ?role= callers migrated, shared SortParser adopted, and the dead users-list server-side
sort (hardcoded JPQL ORDER BY) fixed. Original notes retained below for context.
✅ Bug fixed — re-diagnosed 2026-06-27 (was: “role filter not applied”). The original diagnosis (filter parser doesn’t apply the role predicate) was wrong. The singular
rolefilter key works end-to-end:UserController.listUsers→extractFilterString(filter, "role")→UserService.listUsersPaginated→UserRepository.findByRolesContainingWithRoles. Proven byUserControllerIntegrationTest.should_filterByRole_when_roleFilterProvided(passing,filter={"role":"ORGANIZER"}→ exactly the ORGANIZER user) and exercised in prod by the FEuserManagementApi(filterObj.role = …) + the email-forwarder Lambda. The spec, FE, and controller all standardize on the singularrolekey ({"role":"SPEAKER"}). Actual root cause: the Bruno tests15-list-users-filter-by-role,19-list-users-combined-filters,20-list-users-filter-by-role-organizerstill sent the removed top-level?role=…param (the pre-collapse contract), whichlistUsersno longer reads — so the result set was role-unfiltered and the tests’roles-array assertions failed. The{"roles":…}(plural) in the earlier note conflated the response projection field with the filter key. Fix applied: migrated the 3 Bruno tests to?filter={"role":…}(combined test foldsactiveinto the same JSON object). No production/code change. (The other documented filter keys —company,active,search— already have backing predicates and pass.)⚠️ Encoding gotcha (2026-06-27, cost a failed PR re-run). A JSON
filterwith raw{ } "in a Bruno.bruURL is rejected by the API gateway with 400 (RFC 3986 disallows unencoded braces), and Bruno also silently DROPS aparams:queryvalue containing raw braces (→filter=null→ unfiltered → role-mismatch). Neither the parse-only check nor aparams:queryblock catches this. Working form: percent-encode the filter in theurl:line (?filter=%7B%22role%22%3A%22ATTENDEE%22%7D) and disable the readableparams:querytwin with~filter:. Always run filter-based Bruno tests against a LIVE service, not just--env unreachparse checks. Validated live: full Bruno suite 14/14 on bothapi-consolidationandapi-consolidation-phase7;listUsersATTENDEE→20, ORGANIZER→6, combined→12/13 (role predicate confirmed applied). Same encoding fix landed on both branches.
Goal (ADR-013 §3): list endpoints expose one vocabulary (filter/sort), not ad-hoc
role/company/search/status/sortBy/sortDir. Lowest-risk approach: keep the
service/repository logic; change only the controller param surface and parse filter/sort
into the existing service args.
listUsers(UserController.listUsers+users-api): droprole/company/sortBy/sortDir. ⚠️?role=is an infrastructure contract — the email-forwarder Lambda callsGET /api/v1/users?role=ORGANIZERunauthenticated (infrastructure/lambda/email-forwarder/ sender-auth.ts:96,address-resolver.ts:130). Update BOTH Lambda files to the newfilterform in the same commit (+ their unit testsinfrastructure/test/unit/email-forwarder.test.ts).listNewsletterSubscribers(NewsletterController+ events-api): dropsearch/status/sortBy/sortDir. ⚠️ Current params are enum-validated (status: [all,active,unsubscribed],sortBy: [...]); prefer keeping that validation (don’t dump everything into a stringly JSON blob — fold sort into a singlesort=-subscribedAtand keep typed filter params, or document the filter keys).- Frontend callers:
web-frontend/src/services/api/userManagementApi.ts(builds role/company/sortBy/sortDir),web-frontend/src/services/api/newsletterApi.ts(builds search/status/sortBy/sortDir). - Tests: backend controller/integration tests for both list endpoints; FE
newsletterApi.test.ts, user-mgmt tests.
Phase 4 — remaining EMS items (CUMS + lifecycle already done)
sessionPUT vs PATCH (events-apiPUT /events/{eventCode}/sessions/{sessionSlug}):UpdateSessionRequestis byte-identical to create & mostly optional → make PUT a true full-replace (require full shape) OR demote to PATCH (recommended: PATCH). BackendSessionController.- Name the inline
objectrequest bodies (events-api):assignSpeakerToSession,declineSpeaker,patchMyNewsletterSubscription,batchImportSessions. Spec typing → regen FE types (gives named TS types). Low risk. - Merge slot-config schemas (events-api):
UpdateEventSlotConfigurationRequest⟷UpdateEventAgendaConfigRequestoverlap → reconcile to one schema or base+extension. - Registration-cancel merge (events-api):
POST /registrations/cancel(email token) vsPOST /registrations/deregister(deregistration token) are two token-cancel flows → keep one (likely-legacy =cancelRegistration). KeepDELETE …/my-registration(authed) +/registrations/deregister/by-email(request-link). FE callers:deregistrationService.ts,eventApiClient.ts(registrations/cancel),registrationService.ts. Behavioral — verify with Bruno + PW. - Partner deactivation — pick one path (partners-api):
isActive=falseinPATCH /partners/{companyName}ANDDELETE /partners/{companyName}(soft-delete) both deactivate. Choose one (recommend DELETE = soft-deactivate; dropisActivefrom the PATCH body), updatePartnerController+ FEpartnerApi.ts+ tests.
Then Phase 5 (partner 5→2) and Phase 6 (events-api decomposition)
Full specs are in the Phase 5 and Phase 6 sections below. Both are large, single-PR efforts. Recommended order: finish 3 + 4 (above), then 6 (decomposition — unblocks cleaner per-domain specs), then 5 (partner consolidation).
Versioning & deprecation approach
All specs are under /api/v1. We are not cutting /api/v2. The cleanups fall in three
buckets:
- Dead-spec removal (no behaviour change). Endpoints that the server never implemented
or no longer serves (events-api UUID
/topics/{id}, partners-api voting subsystem). These are spec-only deletions — zero runtime risk once verified the server doesn’t serve them. Do these first. - Documentation-truth fixes (no behaviour change). Inline
ErrorResponse/PaginationMetadata→ shared stub; doc corrections; spec for undocumented live routes. No client impact. - Genuine contract changes (client impact). Removing a redundant live endpoint
(second upload initiator), dropping a PUT twin, collapsing a list query vocabulary.
These get: (a) a usage check against access logs / frontend service layer, (b) the
redundant endpoint marked
deprecated: truein the spec for one release, © removal in the following release once callers are migrated.
Rule: prefer deprecate-then-remove for anything a client could call; delete outright only for verified dead spec.
Phase 0 — Shared-kernel contract (do first; unblocks everything)
The shared contract is the foundation; fixing it makes every per-service fix a stub reference instead of a bespoke schema.
[REMOVE]shared-kernel/.../utils/ErrorResponse.java(thetraceIdduplicate). Migrate callers (shared/utils/ErrorHandlingUtils.java) todto.ErrorResponse.[KEEP-BUT-FIX]Makech.batbern.shared.dto.ErrorResponsethe sole wire shape:timestamp, path, status, error, errorCode, message, correlationId, severity, details.[KEEP-BUT-FIX]Reconcile theerrorCodefield with theErrorCodeenum (ERR_*) — currently unrelated. Either type the field to the enum or document them as separate namespaces.[KEEP-BUT-FIX]PromoteValidationError {field, message}into shared-kernel (today onlyauth-endpointsdefines it).[MERGE]Createdocs/api/_shared.openapi.ymlholding the singleErrorResponse,PaginationMetadata,PaginatedResponse,ValidationError—$ref/stub-referenced by every spec so the wire schema has one literal source, not 13 copies.[KEEP-BUT-FIX]Rewritedocs/architecture/04-api-core.mderror + pagination sections to match reality (flat error, page-based pagination). It currently documents a nested-error/offset-pagination contract nothing implements — the single most misleading doc in the set.
Risk: low (internal types). Client impact: none.
Phase 1 — Dead-spec removal (no runtime risk, big clarity win)
EMS — kill the duplicate topic surface in events-api
The live TopicController serves {topicCode} (verified); the events-api /topics/{id}
UUID block is stale. topics-api.openapi.yml is canonical.
[REMOVE]from events-api:GET/POST /topics,GET /topics/{id},GET /topics/{id}/similar,GET /topics/{id}/usage-history,PUT /topics/{id}/override-staleness,POST /topics/recalculate-staleness,POST /topics/calculate-similarities,POST /events/{eventCode}/topics(inline body).[KEEP-BUT-FIX]topics-apibecomes the single canonical topic spec. Standardize operationIds (getTopicByCode,calculateSimilarities). If override/recalculate staleness are still wanted, re-add them to topics-api asPOST /topics/{topicCode}/override-staleness(action, not PUT) +POST /topics/recalculate-staleness.
Partner — kill the orphaned voting subsystem in partners-api
Zero implementation exists (verified: no voteValue / TopicVoteResponse / getPartnerVotes
in code). The live voting API is the toggle in partner-topics-api.
[REMOVE]GET/POST /partners/{companyName}/votes,GET/POST /partners/{companyName}/suggestions.[REMOVE]schemasCastVoteRequest,TopicVoteResponse,SubmitSuggestionRequest,TopicSuggestionResponse,SuggestionStatusand theTopic Votingtag.- This also auto-resolves the duplicate
operationId: castVoteand the conflicting slug-vs-UUIDtopicIddefinitions.
Risk: low (verified dead). Action: confirm no frontend service-layer or Bruno test references these paths before deleting.
Phase 2 — Document the real surface (specs under-document live routes)
[KEEP-BUT-FIX]Add to partner specs the undocumented live endpoints:GET /partners/me,DELETE /partners/topics/{topicId},GET /partner-meetings/{id}/rsvps(+ note the/internal/...rsvpscallback).[KEEP-BUT-FIX]Re-enable the commented-out generators inservices/speaker-coordination-service/build.gradleandservices/attendee-experience-service/build.gradle(2 commented blocks each), adding the sharedimportMappings/schemaMappingsblock. Until then, speakers/attendees contracts are unenforced.[KEEP-BUT-FIX]AddimportMappings/schemaMappingsto the partner-coordination client- DTO tasks (generateCompanyClientDtos,generateUserClientDtos).[KEEP-BUT-FIX]Decide owners + generator wiring for the 6 orphan specs (auth-endpoints,file-upload-api,partner-analytics-api,partner-meetings-api,partner-notes-api,partner-topics-api) — none feed any generator today.
Risk: low–medium (re-wiring may surface latent drift — that is the point).
Phase 3 — Convention conformance (documentation-truth, no behaviour change)
Reuse shared types instead of redefining
[KEEP-BUT-FIX]Convertcompanies-api,users-api,topics-apiErrorResponse+PaginationMetadatafrom full inline bodies to the stub form (x-java-type+x-java-type-import, events-api style). They already codegen via name-matchedschemaMappings; this removes silent doc-vs-codegen drift.[RENAME]Standardize the stub annotation tox-java-typeeverywhere; dropx-java-class(partners).[RENAME]speakers-api: renamePagination→PaginationMetadata, addhasNext/hasPrev; replace its bespoke inlineErrorResponsewith the stub.[KEEP-BUT-FIX]Give the no-error-schema specs (attendees-api,partner-analytics,-meetings,-notes,-topics) a referencedErrorResponseon 4xx/5xx.
One list-query vocabulary
[KEEP-BUT-FIX]users-api listUsers: drop ad-hocrole/company/sortBy/sortDir; keep JSON:APIfilter/sort.[KEEP-BUT-FIX]events-api GET /newsletter/subscribers: replace ad-hocsearch/status/sortBy/sortDirwithfilter/sort.[KEEP-BUT-FIX]Addsorttoevents-api GET /events/{eventCode}/registrations,partners GET /partners, and add pagination tolistPartnerMeetings/listPartnerNotes/listTopics(or document them as bounded).
get-or-create
[RENAME]companies-api:POST /companies:get-or-create→POST /companies/get-or-create(slash), matching users. Mark the colon formdeprecatedfor one release if any client uses it.
Risk: low (mostly spec text); the list-query changes are client-affecting → deprecate-then-remove.
Phase 4 — Mutation-model fixes (genuine contract changes; deprecate-then-remove)
CUMS — collapse the PUT/PATCH twins
[REMOVE](deprecate first)PUT /companies/{name}andPUT /users/me— they reuse the all-optionalUpdateXRequestand are functionally identical to the PATCH. Keep PATCH as the single mutation. Or, if a true full-replace is wanted, give PUT a required-fieldReplace<Entity>Requestper ADR-013 §1 — but only if the difference is real.[KEEP-BUT-FIX]ReconcilePATCH /users/me(broadUpdateUserRequest) vsPATCH /users/{username}/profile(narrowPatchUserProfileRequest): document the ownership boundary or unify the field set. Pick one patch philosophy.
CUMS — remove the duplicate upload initiator
[REMOVE](deprecate first)POST /users/me/picture(uploadProfilePicture). KeepPOST /users/me/picture/presigned-url(clearer name, supports SVG). Both currently “generate a presigned S3 upload URL.”
EMS — one event-lifecycle transition model
[MERGE]POST …/publish+POST …/workflow/advance+PUT …/workflow/transition→ one model. KeepPOST /events/{eventCode}/workflow/transition(TransitionStateRequest) as the general transition (POST, matching the speaker confirm/decline action style); demotepublish/advanceto documented convenience shortcuts or remove.[KEEP-BUT-FIX]PUT /events/{eventCode}/sessions/{sessionSlug}:UpdateSessionRequestis byte-identical to create and mostly optional → either make it a true full-replace (require the full session shape) or demote to PATCH.[KEEP-BUT-FIX]Name the inlineobjectrequest bodies (assignSpeakerToSession,declineSpeaker,patchMyNewsletterSubscription,batchImportSessions).[MERGE]ReconcileUpdateEventSlotConfigurationRequest⟷UpdateEventAgendaConfigRequest(overlapping slot vocabularies) into one schema or base+extension.
EMS — registration cancellation (4 entry points)
[MERGE]POST /registrations/cancel(email token) andPOST /registrations/deregister(deregistration token) → one token-cancel endpoint (the likely-legacy one iscancelRegistration). KeepDELETE …/my-registration(authed) and/registrations/deregister/by-email(request-link) — distinct and justified.
Partner — pick one deactivation path
[KEEP-BUT-FIX]Deactivation exists twice:isActive=falseinPATCH /partners/{companyName}ANDDELETE /partners/{companyName}(soft-delete). Choose one (recommend DELETE = soft deactivate; removeisActivefrom the PATCH body, or vice-versa) and document it.
Risk: medium (live endpoints). Process: usage check → deprecated: true one release
→ remove. Regenerate frontend types (npm run generate:api-types) and update the service
layer after each spec change.
Phase 5 — Partner spec consolidation (5 specs → 2)
The partner resource is fragmented: core CRUD + contacts live in partners-api, but
notes and analytics are literal /partners/{companyName}/* sub-resources living in
separate files. No single source of truth for “what can I do with a partner.”
[MERGE]Foldpartner-notes-api,partner-analytics-api,partner-topics-apiintopartners-api.openapi.yml(all under the/partners/*tree). Wire the single spec to the generator — today onlypartners-apiis generated; the others are hand-implemented and drift-prone.[KEEP]partner-meetings-apistays separate (top-level/partner-meetingsaggregate), brought to parity (shared error schema, pagination, documented RSVP routes).[KEEP]POST /attendees/topics(suggestCommunityTopic) — relocate into the merged spec (served by partner-coordination-service); document the/attendeesprefix as an intentional attendee-facing alias.[RENAME]ClarifygetPartnerStatisticsvsanalytics/dashboardboundary (e.g.partnership-summaryvs attendanceanalytics).[KEEP-BUT-FIX]Normalize tags (top-leveltagsin every spec;Topic Voting→Partner Topics) and servers blocks (relative/api/v1everywhere).
Risk: low (spec reorganization + generator wiring); behaviour unchanged.
Phase 6 — events-api decomposition (one pass, strategy B) — ✅ DONE
Landed as described below, with two judgment-call deviations recorded during the carve:
- teaser-images stayed in
events-core(notevent-media).EventembedsTeaserImageItem, and teaser DTOs have no shared-kernel class to “lift to_shared” — so the plan’s own fallback (“keep teaser in events-core to avoid the split”) was taken.event-media-api= Event Photos only.core → sessionsis a real cross-spec$ref(Event embedsList<Session>). openapi-generator 7.2.0 emitted illegalList<@Valid <fully.qualified.Name>>for aschemaMappingscross-package type (bug #17647). Was worked around by referencing Session/SessionSpeaker viaimportMappings(import + simple name) and deleting the dead duplicate copy the core task then emitted, via adoLastonopenApiGenerate. ⚠️ This workaround was REMOVED in Phase 9a (generator upgraded to 7.14.0, which fixes #17647 → emits the legalpkg.@Valid Type); Session/SessionSpeaker are now plainschemaMappings. All other 7 specs depend only on shared-kernel — a clean DAG.Two defined-but-unreachable schemas (
Speaker,RegistrationAdminResponse) were initially preserved (pure-reorg caution), then removed as a follow-up once confirmed dead:Speakerduplicated User profile fields (firstName/lastName/company/profilePhoto) — an ADR-004 violation with no endpoint and no live consumer (its only FE aliasSpeakerUIwas itself unused; the real type isSessionSpeaker= enriched User + speakerRole/presentationTitle/isConfirmed).RegistrationAdminResponsehad no path, no hand-written code reference (only a Javadoc comment), and no FE use. Both deletions verified: 122/122 ops still intact, 0 dangling refs, backend compiles, FE type-check clean.
events-api.openapi.yml is a single flat 11,276-line file: 104 paths, 134 schemas, 17
tags, 0 external $refs, bundling ~9 unrelated sub-domains (newsletter, AI, watch,
email-templates, analytics have nothing to do with the event lifecycle). It mirrors the
EMS “ball-of-mud” — and is behind the code, which is already 43 domain controllers.
This decomposes the spec, NOT the service. It is fully compatible with the hard
anti-goal in ems-modularization-extension-points.md
(“❌ do not split EMS into more microservices”): one deployable stays; the contract gains
vertical modules to match the controller layout. Precedent already exists — topics-api is
a separate per-domain spec generated by the same service (openApiGenerateTopics).
Why a clean cut (measured): of 132 path-reachable schemas, only 10 are shared across
more than one tag-cluster — 2 are the shared-kernel types (handled in Phase 0) and the
other 8 (Event, Session, SessionSpeaker, EventType, Venue, EventWorkflowState,
2× teaser) live entirely within the core-event family. ~92% of schemas are single-domain.
Target spec set (carve in one PR)
Replace events-api.openapi.yml with the lean events-core-api + the siblings below.
Each becomes its own docs/api/*.openapi.yml, generator-wired like topics-api. Paths are
preserved verbatim — this is reorganization, so no client impact.
| New spec | Source tags / paths | ~ops |
|---|---|---|
| events-core-api | Events, Event Types, Bulk Operations, + lifecycle from Event Actions (/publish, /workflow/*, /thanks*) |
~17 |
| event-sessions-api | Sessions (/sessions*, /agenda-config, /timetable, slot, session-speakers confirm/decline) + QnA (/sessions/{slug}/qna* from Event Actions) |
~14 |
| event-speakers-api | Event-scoped speaker coordination: pool, /self-nominate, /promote (Event Actions) + Speaker Invitation + Speaker Outreach |
~9 |
| event-registrations-api | Registrations (registrations, participants + exports, deregister, distribution-list, batch) | ~15 |
| event-newsletter-api | Newsletter (incl /registrant-notices/*, /slides-online/send, subscriber lifecycle) |
~17 |
| event-media-api | Event Photos + teaser-images (/teaser-images/*, currently under Events tag) |
~8 |
| event-ai-api | AI Assist (/ai/*, /analyze-abstract) + AI Prompts (/ai-prompts*) |
~8 |
| event-analytics-api | Analytics (/analytics/*, /attendance-summary, /{eventCode}/analytics) |
~7 |
| event-watch-api | Watch (/live-timing*, /watch/*) |
~4 |
| (topics-api) | already separate — the dead /topics/{id} block (Phase 1) is not carried over |
— |
Placement judgment calls (decide during the carve)
[MERGE]Email Templates (2 ops) — too small for its own spec. Fold intoevent-newsletter-api(templates power newsletters/notices) or a smallevent-admin-api.[RENAME]teaser-images shares theTeaserImageItem/Eventschemas with core. Put it inevent-media-apiand lift the 2 shared teaser schemas to_shared(Phase 0), OR keep teaser inevents-coreto avoid the split. Recommend media + lift./attendee-portal/dashboardand/public/settings/featuresare attendee/public reads served by EMS (AttendeeDashboardController) — flag for relocation to the attendee-experience domain rather than any event spec (boundary review).- Watch —
watch-pairingalready lives in CUMS (users-api). Review whetherevent-watch-apibelongs in EMS at all or should consolidate with CUMS watch (Phase 2 re-wires the commented-out speaker/attendee generators — do this boundary review then).
Generator wiring (mechanical, mirrors openApiGenerateTopics)
For each new spec, add an openApiGenerate<Domain> task in
services/event-management-service/build.gradle with:
apiPackage = "ch.batbern.events.<domain>.api.generated"- the shared
importMappings/typeMappings/schemaMappingsblock (→_shared.openapi.yml) x-java-typestub annotations (events-api style) for shared types
Then re-point each domain’s controller(s) to the new generated *Api interface. Because the
code is already split by domain (43 controllers), this is largely a 1:1
implements XxxApi import swap — SessionController → event-sessions-api,
NewsletterController → event-newsletter-api, etc. Verify the build generates all specs
(./gradlew :services:event-management-service:openApiGenerate*) and the *Api interfaces
resolve before deleting events-api.openapi.yml.
Do Phase 1 (dead-topic removal) and Phase 0 (shared _shared.openapi.yml) first — the
carve then inherits a clean schema set and never copies the dead block forward.
Risk: low–medium. Paths unchanged → zero client impact; the risk is purely build-time (generated package moves + controller re-points), caught by compilation. Do it in one PR so the repo never sits in a half-carved state.
Phase 7 — Contract-first completion (controllers → generated interfaces) — ✅ EMS DONE (CUMS/Partner advanced)
Surfaced during Phase 6 but platform-wide, not EMS-only (verified 2026-06-27). The single most valuable follow-up, and the root cause of the Phase 4 PUT→POST drift that Phase 6 had to fix.
As of 2026-06-29: EMS is fully contract-first (42 controllers wired) — see the ▶ STATUS block at the top for the authoritative current state. Remaining:
EventControllerCRUD (deferred FE story),GlobalSession(orphan-remove),Watch*(Phase 8),DevEmail/TestFixtureCleanup(skip); CUMS 6/6; Partner 5/10. The per-controller history below + the ✅ DONE notes near the top are the full record.
Progress (2026-06-27, branch
api-consolidation-phase7, after Phase 9a):
- ✅ Wired:
PresentationSettingsController→PresentationSettingsApi(cbade6c4),PublicUserController→PublicApi(707ecfdc) — both with hand-written→generated DTO consolidation;PartnerContactController→PartnerContactsApi(2abe6b38, no DTO change). Each verified by its integration test + the owning service’s full suite; PublicUser also via Brunousers-api, PartnerContact via Brunopartners-api, PresentationSettings via Playwrightpresentation.spec.ts.- ⏸️ Deferred (documented): Watch controllers (
WatchAuthController.pairreturnsResponseEntity<?>+ ad-hoc{"message":...}body → typed-contract clash affecting the external watch app; record→class DTO migration; method renames) — needs an owner decision + watch-app coordination.PublicOrganizerController+LogoControllerare undocumented (no spec operation / generated interface) → require a spec addition first (Phase-2-style).- ✅ Wired:
CompanyController→CompaniesApi+CompanySearchApi+CompanyVerificationApi(3 interfaces, 9 ops). MERGED the formerCompanyGetOrCreateController(the/companies:get-or-createcolon path combines correctly once the class prefix is/api/v1, not/api/v1/companies). Consolidated all 9 hand-written DTOs → generated twins acrossCompanyController+CompanyService/QueryService/SearchService+UserService; deleted the hand-written DTOs.verified→isVerifiedandInstant→OffsetDateTime(mapper-boundary.atOffset(UTC), entity/DB untouched) done;CompanyLogo.urlstaysURI(no maxLength) via a guardedtoUrimirroringUserResponseMapper. 5 spec/contract-drift bugs surfaced by the wiring + fixed (all behaviour-preserving): (1)websitewasformat:uri+maxLength→ generated@Size URI→UnexpectedTypeException500 on every @Valid body with a website; droppedformat:uri(string + @Size, mirrors users-api profilePictureUrl). (2)namepattern:^[A-Za-z0-9]+$was never enforced by the hand-rolled controller (which allowed spaces, “Swisscom AG”) → relaxed spec to@Size(2..255)to match deployed behaviour. (3)validate-uidhad@Patternon theuidquery param, which would 400 every malformed UID before the handler → madevalid:falseunreachable (contradicting the spec’s own example); removed it (format check stays inSwissUIDValidationService). (4) generated DTOs lacked the hand-written@JsonInclude(NON_NULL)→ null fields leaked, breaking?fields=/?include=sparse contracts; restored via generatoradditionalModelTypeAnnotations. (5) the interface’s@Min(1)onpagenow fires (method validation) →ConstraintViolationExceptionwas unmapped → 500; added aGlobalExceptionHandlermapping it → 400. Full CUMS suite green (730 tests).@WebMvcTestCompanyControllerTestremoved — SB4 slice doesn’t register interface-inherited@RequestMapping; its unique validate-uid/verify coverage moved intoCompanyControllerIntegrationTest(full context), matching the PublicUser/PresentationSettings convention. Validated live against local dev: full CUMS suite 730 green; full Bruno suite 14/14 green (incl. companies-api); manual smoke of list / validate-uid (INVALID-UID→200valid:false) / search all correct. Playwright @smoke gate (incl.company-creation.spec.ts) deferred to CI/staging — local Playwright is blocked by a pre-existing dev-native frontend-port quirk (Vite serves :3000, config/status expect :8100) + documented local-dev divergence.- ✅ Wired:
UserController→ 7 generated interfaces (UserManagementApi,UserAccountApi,ProfilePictureApi,RoleManagementApi,UserSearchApi,DomainIntegrationApi,GdprComplianceApi) — 24 ops. Class@RequestMapping/api/v1/users→/api/v1(interface paths carry/users/…).provisionUser→provisionUserWithRole(interface name);searchUsersnow returns the slim generatedUserSearchResponse(matches the already-deployed FEuserManagementApi.tstype) via a controller-side projection. Consolidated the 4 hand-written profile-picture DTOs:ProfilePictureServicenow returns the generatedPresignedUploadUrl(uploadUrl/profilePictureUrl→URI); deleted the 3 dead hand-writtenProfilePictureUpload{Request,ConfirmRequest,ConfirmResponse}. The hand-writtenPresignedUploadUrlstays for the logo flow (LogoController/GenericLogoService, undocumented — deferred). Five endpoints with nousers-apioperation remain hand-rolled in the same class (re-pathed to/users/…):GET /by-company,PUT /{username},POST /{username}/profile-picture/upload-from-url,POST /admin/reconcile,GET /admin/sync-status→ flagged for a spec addition (Phase-2-style).ActivityHistoryApi.getUserActivityis a dead spec op (no impl anywhere in CUMS) → deliberately NOT implemented (spec-cleanup follow-up). 3 spec/contract-drift fixes surfaced + applied (behaviour-preserving): (1)ProfilePictureUploadRequest.mimeTypeadded optional — the FE sendsfile.typebut the spec omitted it (server derives type from extension regardless); (2)fileSizemaximum: 5242880dropped — generated@Maxpre-empted the service’s friendlierexceeds 5MB limit400; the service stays the gate (the pre-wiring hand DTO had no max); (3) the{username}@Pattern(already in the spec, now enforced via interface method-validation) turns malformed usernames into 400 not 404 — fixed one stale test that probed with the non-conformingno.such.user→ghost.user. 🐛 Wiring gotcha (cost one full red run, 94→0): keeping@Validon the override params made Spring read parameter annotations from the implementation method (only@Valid, no@RequestBody) and STOP inheriting the interface’s@RequestBody→ request bodies never bound → 500 before the handler on every POST/PATCH/PUT. Fix: bare override params (inherit@Valid @RequestBodyfrom the interface), exactly likeCompanyController. Validated live against local dev: full CUMS suite 730 green; Brunousers-api35/35 green (list / get / provision additionalProperties-guard / issue-credentials / patch-profile / delete / public projection); FE type-check +userManagementApi
userAccountApi(54) green. Playwright 35/35 green (organizer project, web on :8100) acrossworkflows/user-management/*(create/delete/list+search/roles),user-account/*(profile/photo-upload/settings/additional-emails),organizer/profile-page, andworkflows/user-sync/reconciliation-drift-fix(the hand-rolled/admin/reconcile+/admin/sync-status) — the deferred UI gate is now satisfied.- ✅ CUMS contract-first DONE (audit correction): all 6 documented production controllers now
implementstheir generated*Api— Company, PublicUser, PresentationSettings, User, and UserPreferences + UserSettings (which were already wired — the earlier “CUMS 2/13” / “4/13” counts undercounted these two). Remaining CUMS controllers are out of contract-first scope: WatchAuth/WatchPairing (deferred — external watch-app contract), Logo/PublicOrganizer (undocumented → spec-first, separate task), DevEmail/TestFixtureCleanup (dev/test-only, not production API).- 🧭 EMS strategy decided (owner, 2026-06-27): RE-TAG specs per controller (not merge controllers). EMS controllers are finer-grained than the Phase-6 spec tags, so one generated
*Apiaggregates ops across many controllers (EventActionsApi= 8 ops / 4 controllers;SessionsApi= 14 ops / 5 controllers;AnalyticsApi= 7 ops / ~3). The plan’s earlier “EMS is a 1:1implements SessionsApiswap” premise was wrong (the 4 pre-wired EMS controllers only worked because their tags were single-controller). Fix: split each controller’s op-group into its own tag → regenerate → 1:1 interface → wire incrementally (spec-only; no controller merges; also satisfies Phase-9 tag hygiene).- ✅ Wired (first EMS via re-tag):
EventWorkflowController→EventWorkflowApi(2 ops) — the very controller whose PUT→POST drift motivated Phase 7. Split the 2 workflow ops out of theEvent Actionsgrab-bag into a dedicatedEvent Workflowtag (publish/thanks/dashboard stay underEvent Actionsuntil their controllers are wired). Class@RequestMapping/api/v1/events→/api/v1. Named the inline transition 200 →WorkflowTransitionResponse(Phase-4 inline-body precedent). Consolidated the 2 hand-written DTOs (TransitionStateRequest,WorkflowStatusDto) → generated; deleted the hand-written twins. Drift fix: the spec’sEventWorkflowStateenum is stale/incomplete (8 values; the domain enum has ~20, and the spec’s ownWorkflowStatusDtoexample usesSPEAKER_OUTREACH, which the enum lacks) — so the generated typed enum would be a regression. Typed the workflow state fields asstringinWorkflowStatusDto/TransitionStateRequest/WorkflowTransitionResponse, matching the deployed String DTOs (state validity is enforced by the workflow state machine, not the wire enum). Drift fix: the eventCode@Pattern ^BATbern[0-9]+$(pre-existing in the spec, now enforced via the interface) rejected the stale test fixtureBAT-2024-Q4→ updated toBATbern142(real codes are allBATbern{n}); same for the not-found probeNON-EXISTENT→BATbern999. Verified: full EMS suite green (10m, Testcontainers);EventWorkflowControllerIntegrationTest12/12.- ✅ Wired (2nd EMS via re-tag):
AnalyticsController→AnalyticsApi(event-analytics spec, 5 ops: getAnalyticsOverview/Attendance/Topics/Companies + getCompanyDistribution). TheAnalyticstag was shared with EventController’s 2 event-scoped ops (getAttendanceSummary,getEventAnalytics); split those out into a new Event Reporting tag (→ unimplementedEventReportingApi, wired when EventController is) soAnalyticsApi== AnalyticsController exactly. Class@RequestMapping/api/v1/analytics→/api/v1(interface carries/analytics/...); controller methods renamed to the operationIds; override params made bare (inherit the interface’s@RequestParam/@Valid/@Min/@NotNull— declaring them on the impl would stop Spring inheriting the interface binding annotations, the UserController gotcha). No DTO consolidation — the controller already consumed the generatedanalytics.dto.generated.*DTOs. New spec-enforced validation now fires via method-validation:fromYear@Min(2000)+eventCode@NotNull/required=true→ConstraintViolationException, already mapped → 400 by the EMSGlobalExceptionHandler(no real input <2000, no test breakage). Verified: EMS main+test compile;AnalyticsControllerIntegrationTest18/18 green (Testcontainers), incl. the unauthenticated-403 and per-event-distribution cases. FE unaffected (re-tag doesn’t change paths/schemas;openapi-typescriptgenerates by path).- ✅ Wired (3rd EMS via re-tag):
DeregistrationController→DeregistrationApi(event-registrations spec, 3 public ops: verifyDeregistrationToken / deregisterByToken / requestDeregistrationByEmail). Split the 3/registrations/deregister/*ops out of the sharedRegistrationstag into a new Deregistration tag (the rest stayRegistrations→ still the unimplementedRegistrationsApi). Class@RequestMapping/api/v1/registrations/deregister→/api/v1; methods renamed to operationIds (verifyToken→verifyDeregistrationToken, deregisterByEmail→requestDeregistrationByEmail); bare override params. No DTO consolidation (already usedregistrations.dto.generated.*). 🐛 Two contract-drift fixes on the verifytokenquery param, both required to preserve the deployed 2026-06-16 truncated-link fix (a mail client truncating the link at?token=must yield 404, never 400/500): (1) spec hadrequired: true→ generated@RequestParam(required=true)would throwMissingServletRequestParameterException→500 on an absent token; changed torequired: false. (2) spec hadformat: uuid→ generated aUUIDparam → a malformed value fails Spring conversion (MethodArgumentTypeMismatchException, not 404); dropped to plainstringso the controller’s defensiveparseTokenOrNotFound(absent/blank/non-UUID →NoSuchElementException→ 404) stays the gate. Spec description updated to document the defensive contract. Verified: EMS main+test compile;DeregistrationControllerIntegrationTest13/13 green (Testcontainers) incl. the NO-token / empty-token / malformed-token → 404 regression cases + anti-enumeration + waitlist-promotion. FE types regenerated (event-registrations: token→optional; also picked up pre-existingevents-coredrift — commit081d43fdchanged that spec but never regenerated FE types); FE type-check green.- ✅ Wired (4th EMS via re-tag):
EventTeaserImageController→TeaserImagesApi(events-core spec, 5 ops: listTeaserImages / generateTeaserImageUploadUrl / confirmTeaserImageUpload / updateTeaserImage / deleteTeaserImage). The 4 documented teaser ops were under the catch-allEventstag; split all into a new Teaser Images tag →TeaserImagesApi== the controller. 🐛 Spec made truthful: the liveGET /events/{eventCode}/teaser-images(listImages) was undocumented (UserController pattern) → added it to events-core (oplistTeaserImages, returnsarrayofTeaserImageItem) so all 5 wire to one interface. Methods renamed to operationIds; bare override params; method-level@PreAuthorize/@CacheEvictkept on the overrides (orthogonal to mapping). No@Patternon the teasereventCode(deliberate — the controller accepts the sentinel_global); the new list op also omits it. No DTO consolidation (already usedcore.dto.generated.*);imageIdpath param isformat: uuid→ generatedUUID, matching the controller. Note: the core generator is the plugin’s defaultopenApiGeneratetask (notopenApiGenerateCore). Verified: EMS main+test compile;EventTeaserImageControllerIntegrationTest12/12 green (Testcontainers); FE types regenerated (events-core gainslistTeaserImages) + type-check green.- ✅ Wired (5th EMS):
AiAssistController→AiAssistApi(event-ai spec, 5 ops: getFeatureFlags / generateEventDescription / generateThemeImage / applyThemeImage / analyzeAbstract). No re-tag needed — event-ai already hasAI Assistas a controller-specific tag (AI Promptsis separate, already wired to AiPromptController).generateDescription→operationIdgenerateEventDescription; bare override params; method@PreAuthorize/@CacheEvictkept. DTO consolidation: deleted the nested hand-written recordApplyThemeImageRequest, threaded the generatedai.dto.generated.ApplyThemeImageRequest(getImageUrl()) through controller + the LLM08 security test. No spec change (interface + DTO already existed) → no FE regen./public/settings/featuresstays here for now (Phase 8 flags it for relocation off the AI controller — wiring it to AiAssistApi today doesn’t block that move). Verified (full live cycle): EMS compile;AiAssistControllerIntegrationTest+AiAssistControllerSecurityTest14/14 green; EMS restarted + live gateway smoke — public feature-flags 200, ai/description no-auth 401, theme-image/apply bad+lookalike URL 400 (LLM08 guard intact, body bound to generated DTO); no real LLM calls.- ✅ Wired (6th EMS, first with real DTO consolidation):
AgendaConfigController→AgendaConfigApi(event-sessions spec, 2 ops: getEventAgendaConfig / updateEventAgendaConfig). Split the 2 agenda-config ops out of the broadSessionstag into a new Agenda Config tag. Methods renamed to operationIds; bare params;@PreAuthorizekept. PUT stays PUT (legit full-replace: all knobs required, copy-on-edit upsert — not a Phase-4 PATCH demotion). DTO consolidation (the real work): deleted hand-written recordsdto.EventAgendaConfigResponse+dto.UpdateEventAgendaConfigRequest; threaded the generatedsessions.dto.generatedtwins throughAgendaConfigService. Boundary mapping:sourceString →AgendaConfigSourceenum;aperitifPositionString ↔ innerAperitifPositionEnum(.getValue()→ DB String column,.fromValue()back);typicalStart/EndTimestay String (generated as String, NOT LocalTime — one less mapping than feared); record accessors → generated getters throughout. Wire values unchanged (@JsonValue) — verified live: GET returnssource=TEMPLATE,aperitifPosition=endexactly as before. Verified (full live cycle):AgendaConfigControllerIntegrationTest10/10 green; EMS restarted + gateway smoke (GET auth 200 w/ correct enum serialization, GET/PUT no-auth 401); no spec schema change → no FE regen.- ✅ Wired (7th EMS, heterogeneous returns + DTO consolidation):
ParticipantsController→ParticipantsApi(event-registrations spec, 4 ops: getEventDistributionList / addParticipant / exportParticipantsXlsx / exportParticipantsDocx). Split the 4 ops out of the broadRegistrationstag into a new Participants tag. Methods renamed to operationIds; bare params;@PreAuthorizekept (none on the anonymous-reachable distribution-list route used by the in-VPC forwarder Lambda). DTO consolidation:Map<String,Object>→ generatedDistributionListResponse(Set→List,kindString → innerKindEnum) +AddParticipant201Response; deleted hand-writtendto.AddParticipantRequest, threaded the generated twin (nullableBooleanforce/notify null-guarded to preserve spec defaults false/true).byte[]exports →ResponseEntity<Resource>(ByteArrayResource) keeping the Content-Disposition/Content-Length headers. 🐛 Drift fix:DistributionListResponse.kindenum was missingparticipants(had only speakers/moderator) though it’s a valid path-param kind the controller resolves → added it (FEkindunion gainsparticipants). eventCode@Patternnow enforced via the interface. Verified (full live cycle):ParticipantsControllerIntegrationTest19/19 green; EMS restarted + gateway smoke — distribution-list participants/speakers 200 (kind=participants now serializes), bogus kind 404, export.xlsx 200 valid ZIP (504b0304) via Resource, POST no-auth 401; FE type-check green. (Historical note from 2026-06-27. EMS per-controller wiring is now COMPLETE — 42 controllers, all wireable, finished 2026-06-29; see the ▶ STATUS block at the top.GlobalSessionControllerended up an orphan flagged for removal rather than wired.)- ✅ Fixed 2026-06-27 (re-diagnosed): the
users-api15/19/20 failures were NOT a missing role predicate — the singularrolefilter works (see §Phase 3 note + passingUserControllerIntegrationTest.should_filterByRole_when_roleFilterProvided). The tests still sent the removed top-level?role=param; migrated them to?filter={"role":…}. Same stale-contract fix also correctedsessions-api 10(PUT→PATCH, per Phase 4). Test-only; no code change.
Problem: most controllers only consume the generated DTOs — they hand-roll
@RequestMapping/@PostMapping/@GetMapping instead of implements-ing the generated *Api
interface. So the OpenAPI spec is not an enforced contract: a controller can map a different
verb/path than the spec declares and nothing fails to compile (exactly how EventWorkflowController
PUT→POST silently diverged from EventWorkflowControllerIntegrationTest). ADR-006’s intent is
controller-implements-generated-interface. Audit of implements <X>Api coverage:
| Service | Controllers implementing generated *Api |
Notes |
|---|---|---|
| EMS | 4 / ~45 | EventTypes, SpeakerOutreach, AiPrompts, EmailTemplates only |
| CUMS | 2 / 13 | the worst ratio — not “mostly wired” (earlier assumption was wrong) |
| Partner | 5 / 10 | Phase 5 wired the notes/topics/analytics controllers; ~half remain |
| Speaker / Attendee | 0 / 0 | dormant; generators off per ADR-014 — out of scope until reactivated |
[KEEP-BUT-FIX]EMS — wire each controller toimplements <Domain>Api(the 9 per-domain interface sets now exist post-Phase 6 → largely a 1:1 import +@Overrideswap, e.g.SessionController implementsevent-sessionsSessionsApi). The generated interface carries the mapping annotations, so the controller drops its own@RequestMappings.[KEEP-BUT-FIX]CUMS — 11 of 13 controllers unwired (CompanyController,UserController, etc. hand-roll mappings againstcompanies-api/users-api). Same fix; this is where the gap is widest.users-apiis also the next-largest spec (~94 KB) — a candidate for its own decomposition if it keeps growing.[KEEP-BUT-FIX]Partner — finish the remaining ~5 controllers againstpartners-api/partner-meetings-api(Phase 5 did notes/topics/analytics).[KEEP-BUT-FIX]Consolidate hand-written DTOs that shadow generated schemas. Verified in EMS (AttendeeDashboardResponse,SessionImportDetail,SpeakerPoolResponse,SpeakerContentInfo, … live inch.batbern.events.dtonext to a generated twin; the generated one is emitted but unused). Not yet audited in CUMS/partner — audit them too as part of wiring each. Thread the generated DTO through and delete the hand-written twin (the deletedSpeakerschema was the same disease).
Risk: medium — behaviour-sensitive (verb/path/response shape must match the spec exactly; mismatches surface as 404/405/400). Do per-controller, run that service’s integration suite each time (the Phase 6 pre-push hook fix now runs them). NOT a single big-bang PR — slice by service, then by domain within the service. Suggested order: CUMS (widest gap) → EMS → finish Partner.
Phase 8 — Domain-boundary corrections (endpoint relocation + path normalization) — 🟡 PARTIAL (8.1 done; 8.2 deferred; 8.3 owner-skipped)
Behaviour/contract-affecting, so deliberately deferred from the Phase 6 reorg.
- ✅ DONE (2026-06-29):
GET /public/settings/featuresrelocated offAiAssistController. Feature flags are an app-settings concern, not an AI one. Bootstrapped a newevent-app-settings-apispec + generator (openApiGenerateAppSettings→ch.batbern.events.appsettings.{api,dto}.generated, wired into compileJava.dependsOn + sourceSets + clean, modelled onopenApiGenerateTasks). NewPublicSettingsController implements AppSettingsApi(class@RequestMapping /api/v1, readsAiConfig.isAiEnabled()); removedgetFeatureFlags+FeatureFlagsResponse+ the now-orphanAiConfigfield/import fromAiAssistController; removed the op + schema fromevent-ai-api.yml. Path preserved verbatim (/public/settings/features, stillpermitAll—SecurityConfigis path-based) so the FEuseFeatureFlagshook is unchanged. Moved the integration test toPublicSettingsControllerIntegrationTest; fixedAiAssistControllerSecurityTest’s direct constructor call (dropped theaiConfigarg). FE: addedgenerate:api-types:event-app-settings(+ events chain), regeneratedevent-app-settings-api.types.ts(new) +event-ai-api.types.ts(FeatureFlagsResponse removed), updated the generatedREADMEmapping. Verified (local dev): EMS compile main+test;PublicSettingsControllerIntegrationTest+AiAssistControllerIntegrationTestAiAssistControllerSecurityTestgreen; EMS restart + live gateway smoke (/public/settings/features200{aiContentEnabled:false}via gateway + direct,/ai-promptsstill 401 → AI controller intact); FEcheck:api-typesstable (only the 3 intended files; tag edits produced no TS drift) +type-check.
- ⏭️ DEFERRED (unchanged):
GET /attendee-portal/dashboardis attendee-domain logic in EMS. Plan- ADR-014 flag it for relocation to attendee-experience; revisit when/if that service is reactivated.
- ⏭️ SKIPPED by owner (Nissim, 2026-06-29): Watch path normalization.
event-watch-apipaths hard-code the/api/v1/prefix while every sibling relies on the server base path. Investigation finding: the generatedWatchApi.javais not implemented by any controller (the watch controllers are hand-rolled with literal@RequestMapping("/api/v1/...")), so stripping the prefix in-repo would be doc-only with zero runtime effect; AND the iOSapps/BATbern-watchapp vendors its own oldevents-api.openapi.ymlcopy and hand-builds URLs (host base + literal/api/v1/), so it does not consume this spec. The inconsistency is therefore latent + harmless. Owner chose to leave it as-is rather than risk a versioned change to the external watch client that can’t be built/tested from this repo. If revisited: strip/api/v1from the 4 paths (server base already carries it), regenerate the unusedWatchApi, and treat the iOS re-sync as a separate watch-app task.
Risk: the one client-affecting item (8.3) was skipped, so what landed (8.1) is path-preserving, zero client impact.
Phase 9 — Tooling & spec hygiene — ✅ DONE
Low-risk cleanup; no contract change.
- ✅ DONE (Phase 9a, branch
api-consolidation-phase7) — Upgraded openapi-generator 7.2.0 → 7.14.0. The cross-package@Validbug (#17647) is fixed in 7.14.0: it now emits the legalpkg.@Valid Type, so thecore→sessionsworkaround (importMappings+ thedoLastdeleting the dead duplicateSession/SessionSpeaker) was removed —Session/SessionSpeakerare back on plainschemaMappings, no duplicate, nodoLast. Spike outcome (regression-tested before adopting): file set identical, whole Java monorepo compiles main+test, full suites green. Migration churn was tiny — (a) optional query/body params changed fromOptional<X>to@Nullable X(one wired controller,EmailTemplateController, adapted); (b) generator no longer strips a common enum-value prefix (WELCOME→AFTER_WELCOME,JPEG→IMAGE_JPEG) — wire values unchanged, only the Java constant identifier (4 refs inEventTeaserImageServiceTestadapted); © thejava/nativemodels-only client generator now injects an unsatisfiedApiClientimport, so partner’sgenerateCompanyClientDtos/generateUserClientDtoswere switched to thespringgenerator (consistent with EMS’sopenApiGenerateUsersClient); and (d) 7.14 default-initialises collection fields to empty (was null), which made unset lists serialise as[]instead of absent and broke the?include=sparse-fieldset contract — restored to pre-7.14 behaviour by addingcontainerDefaultToNull: 'true'to every generatorconfigOptionsblock (CUMS ×2, EMS ×11, partner ×3). Everything else additive (per-DTO innerBuilder,@Nullablefield annotations). Frontend types are generated by the separateopenapi-typescriptnpm tool and are not affected by this Gradle-plugin bump. Done before Phase 7 so the ~50 controllers wire against the final 7.14.0 interface shape exactly once. - ✅ DONE (2026-06-29): declared top-level
tagsin the two specs that were missing them. After Phase 7’s churn the only specs with operation-level tags but no top-leveltags:block wereevent-media-api(now declaresEvent Photos+Materials Upload) andpartner-meetings-api(now declaresPartner Meetings+Partner Meeting Rsvps). The plan’s earlier mention ofSpeaker Invitation/Organizerin event-media was stale — those tags are no longer there, so the “fold the single-op Organizer tag” step is moot. Also added the undeclaredAI Promptstag toevent-ai-api’s top-level block while editing it for 8.1. No TS/codegen impact (top-level tags don’t affectopenapi-typescriptoutput —check:api-typesstable). - ✅ DONE (2026-06-29): fixed stale Javadoc in
web-frontend/src/services/workflowService.ts(header comment saidPUT …/workflow/transition; the call has beenPOST— corrected; no other PUT references remain in the file).
Risk: low.
Patterns to standardize on (the org reference, by example)
These already exist in the codebase and become the canonical examples in ADR-013:
- PUT-full-replace vs PATCH-partial —
events-api/events/{eventCode}(UpdateEventRequest9-required vsPatchEventRequest0-required, controller enforces it). - POST-create + PATCH-update split —
events-apispeaker pool. - Action sub-resources for state changes — EMS speaker
…/confirm/…/decline/…/promote; CUMS…/verify/…/roles. - Idempotent toggle —
partner-topics-apiPOST/DELETE /partners/topics/{id}/vote. - PATCH-only, no PUT — the entire partner domain (never shipped PUT/PATCH twins).
- Single
ErrorResponsefor 100% of error responses —events-api/topics-api. - POST-create + explicit
select/assigninstead of get-or-create — EMS topic selection.
Suggested execution order
| Phase | Risk | Client impact | Gate |
|---|---|---|---|
| 0 Shared-kernel | low | none | unit tests green; both ErrorResponse callers migrated |
| 1 Dead-spec removal | low | none (verified dead) | grep frontend + Bruno for the paths; confirm server 404s |
| 2 Document live routes | low–med | none | generators re-enabled, build green |
| 3 Convention conformance | low | low (list query) | codegen unchanged; deprecate list-vocab |
| 4 Mutation-model fixes | med | yes | deprecate-then-remove; regen FE types + service layer |
| 5 Partner consolidation | low | none | merged spec generates; controllers unchanged |
| 6 events-api decomposition | low–med | none (paths preserved) | all per-domain specs generate; controllers re-point; build green; one PR |
| 7 Contract-first completion | med | none (verb/path must match spec) | per-domain slices; each service’s integration suite green |
| 8 Domain-boundary corrections | med | yes (watch app, flag callers) | usage-check + migrate callers; version watch path change |
| 9 Tooling & spec hygiene | low | none | generator upgrade regression-tested; build green |
Land 0→1→2→3 as the low-risk consolidation pass; schedule 4 and 5 as follow-ups with the deprecate-then-remove cycle so no live client breaks. Phase 6 (events-api decomposition) depends on 0 + 1 (clean shared schemas, dead block gone) and is done as a single PR.
Phases 7–9 were surfaced during Phase 6 and deliberately left out of the pure-reorg PR.
Recommended order: 7 first (contract-first completion — highest value, closes the
drift class that bit Phase 4), then 8 (boundary corrections, client-coordinated), then 9
(tooling/hygiene, anytime). 7 is enabled by 6 (the per-domain *Api interfaces now exist).
EMS note: this plan is the input for applying ADR-013 to EMS. The EMS-specific work (Phase 1 dead-topic removal, Phase 4 EMS mutation fixes, Phase 6 spec decomposition) is the API-contract complement to the internal-module work in
ems-modularization-extension-points.md— the spec carve (Phase 6) and the controller vertical modules should land on the same domain boundaries.