feature/api-spec-first - #300
Open
dapolach wants to merge 52 commits into
Open
Conversation
EventCategory (id + orisId + name + optional fee) replaces String in Event.categories. Covers domain, persistence (memento + DDL), REST DTOs, HAL-FORMS affordances, and frontend event forms/detail. Registration still references categories by name — changed in a later iteration. Implements iteration 1 of openspec change event-category-identity. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
EventRegistration.category (String) becomes categoryId (EventCategoryId). The display name is resolved from Event.categories at read time; a registration whose category was removed reads back with category: null (orphaned). Covers domain, persistence, REST DTOs, sort-by-category, HAL-FORMS categoryId affordance, RegistrationEditedEvent, and frontend registration select/display. Implements iteration 2 of openspec change event-category-identity. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Import populates EventCategory.orisId from EventClass.id. Sync now merges incoming categories by orisId (Event.mergeCategoriesFromOris): a match updates the name in place, keeping the local id and attached registrations, so a rename in ORIS no longer orphans registrations. Manually added categories (orisId null) are never removed by sync. Removal warning rewritten to compare by orisId. Also marks task group 3 (data migration) N/A: the project runs on in-memory H2 with no production data and a consolidated V001 schema. Implements iteration 4 of openspec change event-category-identity. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
EventCreatedEvent and EventUpdatedEvent now carry List<EventCategory> (id, name, fee) instead of List<String>. RegistrationEditedEvent already references categories by id. No cross-module consumer reads the categories field, so the shape change is contained. Implements iteration 5 of openspec change event-category-identity. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move EventCategory and EventCategoryId to the events root package so the cross-module domain events no longer reference an internal domain-package type. Add DB unique constraints on event_categories (event_id,name) and (event_id,oris_id) as defense-in-depth for the aggregate invariant. Replace a hardcoded currency literal with a named constant on the frontend. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The event form field factory intercepted the CategoryRequest field type unconditionally, hijacking the array-level categories field and bypassing HalFormsCollectionField. The create form showed a single row with no add/remove and could not submit, and edit did not load existing categories. Guard the single-row branch with isMultipleProperty so the array-level call falls through to the collection field. Adds a regression integration test rendering the real collection field. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Finalizes task tracking: coverage (6.2), pricing-proposal alignment (6.4), and browser QA (6.5) done; data-migration group marked N/A for the in-memory-H2 project. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sync the event-categories and event-registrations delta specs into the main specs (stable category identity, category fee, ORIS origin-based sync matching, registrations kept across category renames) and move the completed change to the archive. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tion recursion Refactor `halFormsFieldsFactory` to support a 3rd `customFactory` parameter, enabling seamless composition of custom field types within `multi` collections. Replace `conf.fieldFactory` threading with an explicit `fieldFactory` prop on `HalFormsCollectionField`. Migrate existing custom factories to leverage the new mechanism, removing the `isMultipleProperty` guard in `eventFormFieldsFactory
…ieldsFactory - Introduced optional 3rd parameter `customFactory` to allow custom field types to be consulted before built-in switch logic - Added `fullFactory` helper to properly wire custom factory into multi/collection branch recursion - Updated HalFormsFieldFactory.test.tsx with test coverage for the new contract - Fixed eventFormFieldsFactory.test.tsx to exercise the updated factory signature This is task group 1 of the refactor-halforms-field-factory proposal. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
… to explicit parameter HalFormsCollectionField now takes the custom-aware field factory as an explicit required prop instead of reading it off shared HalFormsInputProps. Removed fieldFactory from HalFormsInputProps and from HalFormsForm's config threading. Task group 2 of 5 for refactor-halforms-field-factory proposal. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
…mechanism Migrated klabisFieldsFactory, its member-filtered variant, and eventFormFieldsFactory to compose custom field logic via the new 3rd-parameter mechanism (expandHalFormsFieldFactory now built on fullFactory). Removed isMultipleProperty guard from eventFormFieldsFactory. Reworked memberIdFieldRenderer to stop special-casing multi-select as a single checkbox-group. It now always returns the single-field HalFormsMemberId component (extended with read-only branch showing resolved member name), so multi MemberId/UUID fields route through standard collection mechanism like any other multi field. This intentional UI behavior change is documented as design decision D5 in design.md (multi-select member pickers now render row-per-member instead of combined checkbox list). This is task group 3 of 5 for refactor-halforms-field-factory proposal. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
…, and multi-dispatch order Updated hal-navigator-patterns skill component-api.md to document: - New halFormsFieldsFactory(fieldType, conf, customFactory?) 3-param signature - fullFactory(customFactory) and expandHalFormsFieldFactory composition helpers - Multi-first dispatch: collection field routes multi-properties before consulting custom factory - Custom field types must not special-case isMultipleProperty; re-express as single-field components so standard collection mechanism handles multi-value rendering per-row This completes task group 4 (documentation) of the refactor-halforms-field-factory proposal. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
…onField prop, not part of HalFormsInputProps Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
…s-field-factory Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
…fication Collapsed redundant fullFactory/expandHalFormsFieldFactory alias into a single public expandHalFormsFieldFactory export. Extracted shared ReadOnlyDisplay component from HalFormsForm.tsx so HalFormsMemberId's read-only branch reuses the same wrapper/dash/renderMode logic as generic read-only field rendering instead of duplicating it. Removed dead prop.readOnly checks in HalFormsMemberId's write-path now that the read-only branch intercepts first. Exported HalFormsCollectionFieldProps so its test imports the real type instead of a duplicate. No behavior change — full test suite (1915/1915) and tsc --noEmit confirmed clean after cleanup. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
…tory Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
…ype logic Updated `UpdateTrainingGroupRequest.trainers` to use `MemberId` type, fixing frontend rendering bug (`[object Object]` issue). Simplified backend handling and removed `case "List"` dead code in `KlabisFieldsFactory.tsx`. Verified no behavior or schema change.
UpdateTrainingGroupRequest.trainers now uses MemberId instead of raw UUID strings, matching the domain layer and fixing the HAL-FORMS template so the frontend renders a member picker instead of broken "[object Object]" text inputs. Removes the now-dead case "List" branch in KlabisFieldsFactory.tsx and the manual UUID parsing on the controller side. Implements openspec/changes/unify-multi-member-field-rendering. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ndering Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replaced individual `minAge` and `maxAge` fields with `AgeRangeResponse` object across backend and frontend to streamline age range management. Updated OpenAPI schema, backend responses, frontend types, and rendering logic accordingly. Added relevant test coverage.
…Response directly MemberResource had only one implementation. Cross-module link processors now depend directly on MemberDetailsResponse via the members.rest named interface, which Spring Modulith already permits.
Added configuration classes for various domains (Calendar, Events, Groups, Members, Membership Fees) to replace domain-specific ID representations with UUIDs in OpenAPI specs using `SpringDocUtils`.
…tion Groundwork for migrating the REST API from code-first to spec-first. Purely additive — the API is still code-first and klabis-full.json is still produced by springdoc, byte-identical to before. - docs/openapi/spec/: skeleton of the hand-written spec (root document, HAL/HAL-FORMS and ProblemDetail shared components, README) - tools/openapi-bundle/: bundles module specs via $ref, validates x-klabis-* and x-hal-* extensions, and compares the springdoc output against the hand-written spec during the migration (31 Vitest tests) - springdoc output moved to docs/openapi/generated/ (gitignored); copyGeneratedOpenApiSpec keeps klabis-full.json where consumers expect it - new Gradle tasks: openapiBundle, openapiDriftCheck Drift check currently reports all 117 operations as not yet migrated, which is the expected starting state. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
First module migrated to spec-first. The drift check now reports all 7 members operations as matching the implementation; 110 operations across the other modules remain code-first. - docs/openapi/spec/members.yaml: MemberController plus registerMember, with x-klabis-* field security and x-hal-* link/template relations read off the controller postprocessors - .claude/skills/klabis-api-spec: authoring guide for the extensions, module layout and the spec-first workflow - fix drift check: local $refs into components/parameters and components/responses were not dereferenced, so every operation using a reusable component looked mismatched Two places where springdoc misreports the wire format are documented in the spec rather than mirrored: PatchField<T> is transparent on the wire (springdoc reports an object with a `provided` flag), and HAL link/page metadata fields are genuinely required. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
openapi-typescript only understands standard OpenAPI, so link relations and HAL-FORMS template names never reached the generated types — the part of the contract the frontend actually navigates by. A second generator now emits them. - tools/openapi-bundle/haltypes.mjs: renders per-operation *Rels constants, *Hal interfaces and *Resource types (16 tests, 50 total) - npm run openapi runs it after openapi-typescript, producing frontend/src/api/halTypes.ts - MemberDetailPage consumes the generated rels through a typed identity helper instead of string literals; renaming a rel in the spec now fails the type-check (verified by renaming one and watching tsc fail) - src/api/types.ts stays hand-written and documents why: it describes the HAL-FORMS media type, which is defined by the standard, not by Klabis Emits .ts rather than .d.ts — the *Rels constants are runtime values, and a declaration file type-checks but throws on import. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
MemberDetailsResponse and its nested records are now generated from docs/openapi/spec/members.yaml instead of being hand-written. The generated records carry the same field-level security as before — 18 @OwnerId/@OwnerVisible/@HasAuthority annotations, matching the hand-written version exactly — plus Bean Validation derived from standard OpenAPI keywords. - custom pojo.mustache emits Java records (the spring generator only does mutable classes) with x-klabis-* and bean-validation annotations - generated into the existing restapi package, not a subpackage: five cross-module link processors depend on MemberDetailsResponse - DTOs now carry wire types: id is UUID (was MemberId) and MedicalCourseDto.validityDate is LocalDate (was Optional<LocalDate>); mappers and processors adjusted accordingly - spec fixes found by the generator: _links belongs to the EntityModel envelope rather than the payload, and required strings need minLength: 1 to reject "" the way @notblank did Validation messages are now Bean Validation's defaults — OpenAPI cannot express a custom message, so one assertion follows the default text. Tests: 164 run, 0 failures, 2 skipped. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
* fix(openapi): resolve node executable by absolute path in Gradle Exec tasks IDE-launched Gradle daemons don't inherit the interactive shell's PATH, so a bare "node" command fails to start when Node is installed via nvm (not present in system PATH). Resolve the executable path once via nodeExecutable/NODE_EXECUTABLE override or a PATH scan, and reuse it across bundleSpecForCodegen, openapiBundle, and openapiDriftCheck. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(openapi): use nvm-aware shell wrapper instead of node executable lookup The previous fix (resolving an absolute node path from PATH) still failed under IntelliJ IDEA: its Gradle daemon process doesn't necessarily expose nvm's install directories on PATH at all, so no scan can find them. Replace it with tools/openapi-bundle/run-node.sh, a wrapper that sources nvm.sh itself and runs `nvm use 24` before exec'ing node — independent of whatever PATH the calling process was launched with. Gradle's three Exec tasks (bundleSpecForCodegen, openapiBundle, openapiDriftCheck) now invoke this script instead of a bare/resolved node binary. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(openapi): add npm install step to run-node.sh * build(gradle): remove unused mockitoAgent configuration which was failing reimport of project * fix(openapi): use node 22 in run-node.sh to match GitHub Actions runner CI failed with "N/A: version \"v24\" is not yet installed" — GitHub's ubuntu-latest runners ship nvm with Node 22 preinstalled, not 24. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(openapi): auto-install npm deps in run-node.sh, fix broken exec chain CI failed with "Cannot find package 'yaml'" — tools/openapi-bundle has no CI step installing its npm dependencies, so node_modules never existed on the runner. A prior commit attempted to fix this with `exec npm install`, but `exec` replaces the current process, so the following `exec node "$@"` line could never run — the script would always just run npm install and exit, never actually invoking the target script. Install node_modules only when missing, without exec, so control still reaches the final `exec node "$@"`. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…dyAdvice (#299) * feat(hateoas): wrap plain payload DTOs into HAL models via ResponseBodyAdvice Controllers returning a plain payload DTO (the shape the OpenAPI generator can produce) now get their HAL representation assembled in HalResponseBodyAdvice instead of building EntityModel/PagedModel themselves. The controller stores its domain object(s) in HalResponseContext; the advice picks them back up, wraps the payload in EntityModelWithDomain and runs the existing RepresentationModelProcessor postprocessors. This unblocks generating API interfaces from the spec, which cannot express ResponseEntity<EntityModel<T>> (hateoas=true is incompatible with records, schemaMappings cannot express generics, and responseWrapper nests in the wrong order). Spring HATEOAS's own postprocessing only triggers on return values that already are a RepresentationModel, so it is skipped for plain DTOs — this advice is what invokes the postprocessors in that case. Applied to MemberController.getMember and listMembers only; every other controller keeps building its models the existing way and is unaffected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(hateoas): address PR review comments on HAL response advice Ignore non-HAL content types in HalResponseBodyAdvice instead of wrapping every response — only HAL/HAL-FORMS media types get the EntityModel/PagedModel treatment. Move the collection self-link and affordance building for GET /api/members out of the controller and into HalResponseBodyAdvice (self link, derived from the request path/params — no separate authorization check needed since the controller already passed one for this exact request) and a new MemberListPostprocessor (affordances, still authorization-sensitive via klabisAfford). HalResponseContext no longer carries a self-link supplier. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(backend-patterns): document HalResponseBodyAdvice as the HATEOAS pattern Replace the controller-builds-EntityModel/PagedModel-directly pattern with the new approach introduced alongside HalResponseBodyAdvice: controllers return plain DTOs and stash the domain object(s) in HalResponseContext, hypermedia wrapping (EntityModel/PagedModel, self links, postprocessors) happens afterwards in the advice. Documents both ModelWithDomainPostprocessor (per-item) and the collection-level RepresentationModelProcessor<PagedModel<...>> pattern used for collection self-link affordances. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(design-decisions): add ADR-002 for HalResponseBodyAdvice Records the decision to have controllers return plain DTOs and let HalResponseBodyAdvice wrap them into HAL models, including the three generator mechanisms (hateoas=true, schemaMappings, responseWrapper) evaluated and rejected before landing on this approach. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(members): dedupe self-link/affordance logic in HAL postprocessors MemberDetailsPostprocessor and MemberSummaryPostprocessor built an identical self link + status-dependent suspend/resume affordance, differing only in DTO generic type. Extracted into MemberSelfLinkSupport.addSelfLinkWithAffordances, which only touches Member and RepresentationModel.add — neither depends on the DTO type. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Phase 4 of the API-spec-first migration: two modules (members, event-types) are now generated from docs/openapi/spec/ instead of being described by springdoc annotations after the fact. - generate *Api interfaces and payload DTOs per module via openApiModule() - drive endpoint and ownership authorization from x-klabis-authority / x-klabis-owner-id / x-klabis-owner-visible - resolve security annotations across the interface boundary - bridge plain payload DTOs back into the HAL postprocessor pipeline, including unpaginated CollectionModel responses - document the findings in the klabis-api-spec skill Wire format is unchanged: EventTypeControllerTest is bit-identical to its pre-migration version. Known debt: springdoc annotations stay on the controllers because springdoc cannot see them through the generated interface. They go away in phase 7, when openapiBundle takes over klabis-full.json.
Phase 5, first half: the two exploratory modules, 14 operations. - finance (6): proves a module can own paths under another module's URL prefix (/api/members/**), and converts imperative "owner OR FINANCE:MANAGE" authorization into x-klabis-owner-visible + x-klabis-authority - calendar (8): first non-HAL endpoint. IcalFeedController serves a raw RFC 5545 string outside /api/ and stays hand-written; the advice passes text/calendar through untouched Also fixes a real wire defect predating both modules: a 201/204 response with no content block answers 406 to any client sending Accept: application/prs.hal-forms+json. Four operations were affected, including updateEventType/deleteEventType from phase 4. Two silent generator behaviours are now documented in the klabis-api-spec skill: multi-word tags are dropped without warning, and a class-level @RequestMapping path concatenates with the generated interface's absolute paths into /api/foo/api/foo. Full suite green: 3203 tests, 0 failures. klabis-full.json unchanged.
* feat(openapi): migrate events module to spec-first Adds docs/openapi/spec/events.yaml as the source of truth for the events module (20 operations) and reworks the four controllers to implement the generated *Api interfaces. Two tags had to be renamed to survive code generation: - "Event Registrations" -> "EventRegistrations", because the generator silently drops multi-word tags: the build succeeds and the interface simply never appears. - OrisEventController moved off the shared "Events" tag onto its own "OrisEvents" tag. The generator emits one interface per tag, so two controllers cannot each implement half of one. URLs are unchanged. Three operations stay hand-written and are excluded from generation, while remaining fully described in the spec: - getEvent embeds a second, independently-shaped collection alongside the main payload via HalModelBuilder; HalResponseContext supports only a single domain object or a flat list. - the accommodation-list path serves two produces variants (HAL JSON and text/csv) from one operation, which the generator cannot express as a single method signature. Same precedent as IcalFeedController in the calendar module. Endpoints acting on the caller's own registration via @ActingMember, and those authorizing imperatively through EventAffordanceSupport (a "coordinator OR authority" rule @HasAuthority cannot express), carry no x-klabis-authority. Declaring one would add a restriction the code does not have. Also fills in the calendar.yaml event link with operation: getEvent, left dangling until the events spec existed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(openapi): migrate membershipfees module to spec-first Adds docs/openapi/spec/membershipfees.yaml as the source of truth for the module (25 operations across 5 controllers) and reworks the controllers to implement the generated *Api interfaces. Ten hand-written request DTOs are replaced by generated ones; the mapping logic they carried moves to MembershipFeesRequestMapper. The five member-scoped operations keep their imperative assertMemberAccessingSelf checks and gain no x-klabis-owner-* extensions. That check permits only the member themselves, whereas owner-visible has OR-with-authority semantics — declaring it would widen access. getGroup stays hand-written and out of generation: it embeds a second, independently-shaped collection (_embedded.members, per @relation) via HalModelBuilder, which HalResponseContext cannot express. Same precedent as getEvent in the events module. Fee rule types and the campaign status filter stay plain strings rather than named enum schemas. The generator emits an empty Java file for a freshly generated top-level enum on a record DTO — enums only work when mapped onto an existing domain enum via schemaMappings, and neither of these has one. Collection-level postprocessors run for every CollectionModel response regardless of content type, so MembershipFeeTierListPostprocessor is gated on a request attribute to avoid contaminating unrelated endpoints. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(skill): record generator pitfalls found migrating membershipfees Six failure modes that cost time in this module, four of which compile and pass tests while being wrong at runtime: freshly generated top-level enums come out empty, a stripped @RequestBody silently drops HAL-FORMS inline options, collection-level postprocessors run for every CollectionModel in the app, a new port in an @MvcComponent breaks unrelated @WebMvcTest slices, and openapiDriftCheck rewrites the file it claims to only check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(openapi): migrate groups module to spec-first Adds docs/openapi/spec/groups.yaml as the source of truth for all four group controllers (30 operations) and reworks them to implement the generated *Api interfaces. Eleven hand-written request DTOs are replaced by generated ones. Codegen is registered as three openApiModule calls rather than one, because the module spans three Java packages (familygroup, freegroup, traininggroup) and the helper sets modelPackage and apiPackage from a single pkg argument. The spec stays a single file; only the registration is split, which keeps generated types next to the controllers using them. Three authorization patterns coexist here and are transcribed as-is: declarative @HasAuthority becomes x-klabis-authority (7 MEMBERS_MANAGE, 8 GROUPS_TRAINING); FreeGroupController's twelve operations carry no authority because ownership is enforced in the domain service, which receives @ActingMember; and getFamilyGroup checks MEMBERS:MANAGE OR group membership imperatively, an OR-rule x-klabis-authority cannot express. The three detail getters stay hand-written and out of generation. Their responses are records whose fields are List<EntityModel<X>>, each item carrying its own _links — a shape HalResponseContext cannot reproduce. TrainingGroupController's AddMemberRequest is renamed TrainingGroupAddMemberRequest: component names share one global namespace after bundling and it collided with the family-group request of the same name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(openapi): migrate common and oris modules to spec-first Adds docs/openapi/spec/common.yaml and oris.yaml, completing the spec-first migration of every REST controller in the backend (9 operations here). Two multi-word tags are renamed because the generator silently drops them: "My Profile" -> MyProfile, "Password Setup" -> PasswordSetup. PermissionController had no @tag at all and gains "Permissions" — without a tag there is no interface to generate. The oris tag is "OrisImport", not "ORIS": the generator's `apis` filter matches tags by substring, so "ORIS" would also pull in events.yaml's OrisEvents operations. Its `models` list holds a single nonexistent placeholder rather than being empty — like `apis`, an empty models property makes the generator emit every schema in the bundled document instead of none. RootController and DashboardController stay hand-written with no generated interface: both return an EntityModel with no domain object, populated entirely by per-module postprocessors, and each is the only operation on its tag. Their spec entries declare both hal+json and prs.hal-forms+json, matching what the controllers actually produce. PasswordSetupController's three operations carry no x-klabis-authority. WebSecurityCommonConfiguration permits /api/auth/password-setup/** to all callers — they are account-activation endpoints reached before the user has a password, secured by the setup token itself. A YAML comment records this so it is not later "fixed" into a locked-out activation flow. MvcExceptionHandler gains a ConstraintViolationException handler. A generated *Api interface is class-level @validated, so a constrained @RequestParam is rejected by the method-validation interceptor before MVC argument resolution, which the existing HandlerMethodValidationException handler never sees — the response would otherwise be a 500 instead of 400. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(skill): record pitfalls found migrating common and oris Three more silent failure modes: the generator's apis filter matches tags by substring (so "ORIS" also claims "OrisEvents"), an empty models list generates every schema rather than none, and a generated interface's class-level @validated routes constrained @RequestParam failures to ConstraintViolationException instead of the handler MVC would have used. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(openapi): generate APIs for the last hand-written controllers Root, Dashboard and the iCal feed were left without generated interfaces in the earlier phase-5 commits. All three now implement one, so every REST controller in the backend is generated from the spec. Root and Dashboard follow the same plain-payload pattern as every other migrated module: the interface returns RootModel/DashboardModel and HalResponseBodyAdvice builds the envelope. wrapSingle() only wraps when the request context holds a non-null domain object, and these two markers have none of their own, so each controller registers a placeholder string — never serialized, since EntityModelWithDomain.domainItem is @JsonIgnore. The resulting EntityModelWithDomain<RootModel, String> is an EntityModel<RootModel>, which is what the nine contributing RepresentationModelProcessor beans are typed on, so the link index is unaffected. Returning a plain payload does mean the controllers no longer own an EntityModel to add links to inline, so two links move into postprocessors to keep the responses identical: Root's admin link, and Dashboard's self link (wrapSingle adds a self link for collections only, not for single items). The iCal feed keeps text/calendar and a raw String body; a String response is as generatable as a DTO. Its tag is renamed "Calendar Feed" -> IcalFeed because the generator silently drops multi-word tags. The ?token= parameter stays a plain @RequestParam with no authority — authentication comes from the token value via IcalTokenAuthenticationFilter. Adds RootControllerTest: GET /api had no test at all, and the existing per-module processor tests invoke process() directly, so none of them would notice the payload no longer being wrapped — the link index would serialize empty with the whole suite still green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(openapi): declare fee controllers' owner-only access in the spec MemberFeeSummaryController and MemberFeeChoiceController enforced owner-only access imperatively via assertMemberAccessingSelf. That is exactly what x-klabis-owner-visible expresses when no x-klabis-authority is paired with it: HasAuthorityMethodInterceptor computes authorityGranted as `requiredAuthority != null && hasAuthority(...)`, so with no authority declared, ownership is the only path to proceed(). Declaring it alone narrows access to the owner rather than widening it. Replaces the helper on all 5 operations with the declarative extension and adds regression tests asserting a MEMBERS:MANAGE holder still gets 403 — nothing else in the suite distinguishes owner-only from owner-OR-MANAGE. Also corrects the reasoning recorded for the group detail getters: they are not undeclarable because x-klabis-authority is "AND-only", but because group membership is a set on the aggregate, not an owner ID nameable as a parameter. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(events): drop authorization annotations duplicated by the spec events.yaml already declares x-klabis-authority: EVENTS_REGISTRATIONS and x-klabis-owner-visible: memberId on editRegistration and getRegistration, and the bundler emits @HasAuthority, @OwnerVisible and @OwnerId onto the generated EventRegistrationsApi. The copies on the controller were a second source of truth for the same rule, which the klabis-api-spec skill forbids. Enforcement is unchanged: MethodSecurityAnnotations resolves all three annotations across the interface boundary, re-deriving the @OwnerId parameter index per method, so the differing parameter order between the two operations is handled by construction. The springdoc annotations stay on the concrete class, where springdoc can still see them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(openapi): correct the claim that collection processors ignore item type Both the skill and MembershipFeeTierListPostprocessor stated that RepresentationModelProcessorInvoker matches CollectionModel processors by the outer type only, so every one runs for every collection response. That is wrong: it resolves the full generic signature, including for empty collections. A processor typed on EntityModel<X> never sees another endpoint's EntityModel<Y>. The guidance derived from it was wrong too — it prescribed request-attribute gating against cross-contamination that cannot happen. Gating is warranted only when the processor needs data the model cannot carry, which is why RegistrationListPostprocessor reads eventId from a request attribute: an empty registration list has no item to recover it from. No behaviour change; the surviving guard is documented as defensive. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(openapi): return payload DTOs from the group detail getters The three group detail getters were mapped to RepresentationModel<?>, so their response shape was the one thing the spec did not enforce. Restructure their schemas as EntityModelX = allOf[X, envelope] — the pattern every other migrated operation already uses — and point schemaMappings at the payload X, so the generated interfaces declare FamilyGroupResponse / GroupResponse / TrainingGroupResponse and the HAL envelope stays a runtime concern of HalResponseBodyAdvice. The records themselves stay hand-written: their collection fields are List<EntityModel<Item>>, so each item carries its own _links/_templates, which the generator cannot express and the frontend reads directly. The collection rel moves into the *DetailsPostprocessor, since the controller no longer has a model to add it to. This drops the hand-written hasMembersManage / hasTrainingAuthority gates in favour of klabisLinkTo, which omits the link for callers lacking the authority the target operation declares — MEMBERS_MANAGE for listFamilyGroups, GROUPS_TRAINING for listTrainingGroups. Covered in both directions by existing tests. Also correct comments across groups.yaml, build.gradle.kts and MembershipFeeGroupController that claimed these operations were "excluded from generation" or "NOT wired into" their Api interface. They were always generated and @Override-ed; only the return type had been untyped. Wire format unchanged: 3212 tests, 0 failures; klabis-full.json untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(openapi): return payload DTOs from getEvent and fee-group getGroup These were the last two operations mapped to RepresentationModel<?>. Both attach a second, differently-shaped collection under _embedded — registrations for events, group members for fee groups — which is why their return type had stayed untyped while everything else moved to the spec. _embedded is envelope, not payload, so it now takes the same deferred path as _links and _templates: the controller declares the collection via HalResponseContext.embed(...), and HalResponseBodyAdvice wraps the processed model through HalModelBuilder once the postprocessors have run. The generated interfaces declare EventDto and MembershipFeeGroupResponse, and schemaMappings point at those payload types. Declaring the embed on the context rather than in the postprocessor is deliberate. Building either collection needs a port the controller already holds (@MvcComponent beans are scanned by every @WebMvcTest, so injecting ports there would force unrelated slice tests to mock them), and a postprocessor is keyed on payload type, not endpoint: MembershipFeeGroupDetailsPostprocessor is shared with FeeSelectionCampaignController.listGroupsForYear, whose items carry no members collection. Both controllers also stop injecting their own postprocessor to invoke it by hand. That existed because processors do not recurse into models nested by HalModelBuilder; now the model goes through the pipeline normally. No generated *Api interface declares RepresentationModel any more. Wire format unchanged: 3212 tests, 0 failures; klabis-full.json untouched. The empty-collection case is now asserted explicitly — MembershipFeeGroupControllerTest checked only memberCount despite its name, so nothing covered the itemType fallback that keeps an empty list rendering as [] under the right relation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(openapi): restore @notblank via x-klabis-not-blank extension The migration to spec-first replaced hand-written @notblank with a `pattern: '^(?!\s*$).+'` workaround, and on the payment rules' ruleType dropped it entirely, leaving only @NotNull. OpenAPI has no keyword meaning "not blank", so add a Klabis extension: x-klabis-not-blank on a schema property, whitelisted in validate.mjs and emitted as @notblank by the overridden pojo.mustache. 12 pattern workarounds on generated schemas are converted; the field keeps the redundant @NotNull from `required`, which is harmless. Two places deliberately keep the pattern: the validatePasswordSetupToken `token` parameter (only pojo.mustache is overridden, so the extension would be dropped on the stock api template — validate.mjs now rejects it there rather than letting it pass as a no-op), and the schemas whose Java records are still hand-written (CreateEventRequest, UpdateEventRequest and their nested types), where the real @notblank already lives in the record. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(common): cover ConstraintViolationException handler, drop orphan DTO MvcExceptionHandler.handleConstraintViolationException had no direct test — only indirect coverage through a controller slice. Add one that asserts the 400 and the parameter-name-only formatting, plus the empty-violations edge case. Messages are not asserted: constraint messages are localised in this project. Also removes a stale comment in PasswordSetupControllerTest claiming the endpoint returns 500 where the test asserts 400, and deletes PasswordSetupRequest, which has had zero references since it was superseded by the generated SetPasswordRequest. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(openapi): generate UpdatePermissionsRequest from the spec UpdatePermissionsRequest was a record nested inside PermissionController purely for historical reasons — the generator can only emit top-level types, so it had to be redirected via schemaMappings. Declaring the Authority enum in the spec (wire values from its @jsonvalue, not the Java constant names) and marking authorities as uniqueItems lets the generator produce the identical Set<Authority> signature, so the mapping and the nested record both go away. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(openapi): generate the members request DTOs from the spec RegisterMemberRequest, AddressRequest and SuspendMembershipRequest were mapped onto hand-written records although nothing about them required it — unlike UpdateMemberRequest, none uses PatchField<T> or a cross-field rule. They date from the first migrated module, before generating request DTOs became the convention. Generating them needed three gaps closed: a new x-klabis-past extension for @past (which OpenAPI cannot express, and which Member does not re-check), the two Address regexes the spec had been missing entirely, and a maxLength for the suspension note. SuspendMembershipRequest.note becomes a plain String — Optional only ever existed to be unwrapped at the call site, and @ValidOptionalSize existed only to validate it, so both are removed. Custom validation messages do not survive generation, so ten assertions move to the Bean Validation defaults. Two were the only Czech messages in the backend; user-facing localisation belongs in the frontend. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* docs(openspec): propose routing HAL affordances at generated Api interfaces Affordances resolve against the concrete controller, but Java does not inherit parameter annotations from an interface — so every override has to re-declare @RequestBody, and an override that forgets silently loses its HAL-FORMS field metadata. Two overrides are missing it today and are harmless only by coincidence. Spec-free: the wire format does not change. Affordance names come from method names identical on both types, URLs from @RequestMapping on the interface, and the conditions gating each affordance stay in the controllers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(openspec): add design and tasks for affordance interface routing Design records the measured scope (196 production call sites, 97 methods, all with an interface counterpart; only 1 of 24 test call sites converts) and four decisions: which plain linkTo calls to convert and why, keeping @parameter on controllers while @RequestBody moves to the interface, a reflective guard test instead of per-endpoint assertions, and a module-by-module rollout starting with the two known-defective overrides. Scope also covers updating the backend-patterns skill, which currently teaches methodOn(XController.class) in eight examples. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(common): route affordances at the generated Api interfaces Java does not inherit parameter annotations from an interface, so HalFormsSupport's lookup for @RequestBody on the method recorded by methodOn(...) failed whenever a controller override omitted it — and on failure it returns the affordance unmodified, skipping the input payload metadata entirely. PermissionController.updatePermissions is such an override, so its template rendered authorities as readOnly: the one field that PUT exists to change. Pointing methodOn at PermissionsApi/DashboardApi fixes it; the field now carries type and multi instead. Also drops @Valid @RequestBody from three overrides that no longer need to repeat what the interface declares. Spring MVC resolves both from the interface, which the bean-validation tests confirm. Pilot for the affordance-interface-routing change; 19 modules follow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(openspec): mark baseline and pilot tasks complete Records that 1.1 was done with a throwaway @WebMvcTest capture rather than a running server — no OAuth flow needed and the diff is exact. Each module now encodes its finding as a permanent assertion instead of keeping capture files around. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(finance): route affordances at FinanceApi instead of the controller methodOn(MemberAccountController.class) recorded the implementation method, where Java does not inherit the interface's @RequestBody. HalFormsSupport therefore skipped HalFormsInputPayloadMetadata and rendered every property of the deposit, charge and reverse templates as readOnly — including the required amount field, which is the only reason those endpoints exist. Captured the HAL bodies before and after: the sole difference is 8 vanished "readOnly": true entries. Links, targets, affordance names and types are byte-identical. Adds regression assertions on all three templates; verified red by pointing the deposit affordance back at the controller. * refactor(members): route affordances at MembersApi and RegistrationApi No latent defect in this module — the overrides did carry @RequestBody, so the HAL bodies for getMember and the members list are byte-identical before and after. The conversion is preventive: the interface becomes the single place an endpoint's contract is declared. Removing @RequestBody turned out not to be optional here. Hibernate Validator rejects an override that redefines the parameter constraint configuration of the method it overrides (HV000151), evaluating the parameter list as a whole, so dropping @RequestBody while leaving @NotNull on the path variable fails at request time rather than at compile time. Both were removed; @Valid is exempt from that rule and stays on listMembers. Recorded in design.md D2. MemberPermissionsLinkProcessor's cross-module reference to PermissionController now targets PermissionsApi instead, consistent with the rest of the change. * refactor(calendar): route affordances at CalendarApi and IcalTokenApi Third module with the same defect: createCalendarItem and updateCalendarItem rendered all four properties — name, description, startDate, endDate — as readOnly, so neither form could be filled in. The overrides never declared @RequestBody, so HalFormsSupport skipped HalFormsInputPayloadMetadata. Captured before and after: the only difference is 12 vanished "readOnly": true entries. No Bean Validation constraints on these overrides, so the HV000151 rule from design.md D2 does not apply here and nothing was removed. The cross-module link to the event detail now targets EventsApi. * refactor(membershipfees): route affordances at the generated *Api interfaces Five controllers, 38 call sites. No latent defect in this module: the overrides already declared @RequestBody, so the templates rendered correctly. Removing it is the point — the interface becomes the single declaration site. No Bean Validation constraints on these overrides, so the HV000151 rule from design.md D2 does not apply. The module's existing assertions on _templates.addRule and editRule options.inline cover this conversion directly: that metadata comes from HalFormsInputPayloadMetadata, which is exactly what a wrong routing would skip. 280 tests pass. * refactor(events): route affordances at the generated *Api interfaces Six controllers across events and oris, 52 call sites. The overrides carried @RequestBody, so no template was rendering wrong here; removing it makes the interface the single declaration site. cancelEvent's @RequestBody(required = false) was checked against EventsApi before removal — the interface declares the same optionality, so dropping it does not make the body mandatory. getAccommodationListAsCsv keeps pointing nowhere new: it is not a methodOn target, so its missing interface counterpart is irrelevant to this change. 796 tests pass, including the affordance and CSV suites. * refactor(groups): route affordances at the generated *Api interfaces Four controllers, 62 call sites — the largest module. No latent defect: these overrides carried @RequestBody. FreeGroupController implements GroupsApi rather than the FreeGroupsApi its name would suggest, so the conversion is not a mechanical name substitution here. cancelInvitation's @RequestBody(required = false) was checked against the interface before removal. 424 tests pass. * test(hateoas): guard that affordances resolve against the *Api interface Two complementary guards, since the failure mode is silent — a template simply comes back without its fields, and no existing assertion notices. AffordanceRoutingArchitectureTest checks the invariant structurally: no production call site may pass a *Controller.class to methodOn(...). It reads sources rather than bytecode because the target type is an argument, which ArchUnit cannot see. AffordanceAuthorizationTest gains a case exercising the real mechanism: an override that deliberately omits @RequestBody, an affordance recorded against the interface, and an assertion that the resulting field is not readOnly. That is precisely the shape that produced the three defects this change fixed. Both verified red before green. Deviates from tasks.md 5.1, which proposed one reflective test over all controller beans: instantiating affordances outside a request context is not something HalFormsSupport supports. * docs(hateoas): record the interface-routing rule in the skill and an ADR backend-patterns now states the rule first in its HATEOAS section, with a table of which annotations belong on the interface and which on the controller — the springdoc exception makes "remove whatever the interface already has" wrong, and HV000151 makes partial removal fail at request time rather than at compile time. ADR-003 records why the rule exists, including the three endpoints that had shipped with unusable forms. The seven methodOn(XController.class) examples in the skill were corrected. tasks.md said eight; the eighth occurrence is prose, not a call site. * docs(openspec): close out affordance-interface-routing verification Full suite 3221/0/0 against a 3214 baseline — the delta is this change's own tests. Both generated baselines byte-identical. Also documents the guard regex's naming assumption: nothing enforces the *Controller suffix, so a controller named otherwise would slip past the architecture test. * fix(events): withhold the accommodation list's event link from callers who cannot read it The `event` rel was built with plain linkTo, bypassing klabisLinkTo's authorization check. Reaching the accommodation list needs EVENTS:REGISTRATIONS or being the event coordinator; reading the event itself needs EVENTS:READ. Neither implies the other, so a coordinator without EVENTS:READ was handed a link that answers 403. The earlier design doc recorded this as an open question and preserved the bypass. It is a defect, not a deliberate choice, so it is fixed here rather than deferred. Two tests cover both sides of the authorization split; the negative one was verified red against the previous linkTo. No frontend consumer reads this rel — AccommodationListPage builds the event URL itself. Full suite 3223/0/0 (was 3221 before these two tests). * chore(openspec): archive affordance-interface-routing change All 7 task sections complete and shipped in PR #305. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(events): return a plain payload from getAccommodationList Last controller still returning a Spring HATEOAS model. The spec now maps the response envelope onto Collection<AccommodationListItemDto> like every other list endpoint, and HalResponseBodyAdvice does the HAL wrapping. The collection's `event` relation moves into AccommodationListPostprocessor, keeping klabisLinkTo so it stays withheld from a coordinator without EVENTS:READ. eventId comes from the URI template because an event with no registrations yields an empty collection with no item to recover it from. The advice also contributes a `self` link the endpoint previously lacked; declared in the spec and asserted in the test. Items stay bare — an accommodation row is not an addressable resource — and the _embedded key is unchanged, coming from @relation on the payload record. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* refactor(common): replace PatchField with JsonNullable PatchField<T> was a hand-rolled tri-state wrapper (absent / present-null / present-value) with its own Jackson deserializer and Bean Validation ValueExtractor. jackson-databind-nullable 0.2.11 provides the same semantics as JsonNullable<T>, and since 0.2.10 ships a Jackson 3 module — the Jackson 2 artifacts it also declares stay off the classpath because both are `provided` upstream. That removes ~200 lines of bespoke plumbing and, more importantly, makes these DTOs generatable from the OpenAPI spec later: JsonNullable is what openapi-generator emits for nullable properties. Type swap only. The DTOs stay hand-written; generating them from the spec is a separate change. The API maps one-to-one, verified against the library sources rather than assumed: notProvided/undefined, isProvided/isPresent, throwIfNotProvided/ orElseThrow, ifProvided/ifPresent, and patchValue(orig)/orElse(orig) — the last matters because Events relies on an explicit null clearing a field, so orElse must return a present null rather than falling back. RequestBodyFieldAuthorizationAdvice is retyped first and deliberately: it skips any component that is not the wrapper type, so a field that lost its wrapper would silently get no authorization check at all. isPresent() preserves the gate exactly — absent skips, present-null still checks and can 403. PatchFieldTest and PatchFieldDeserializerTest are dropped; they exercised the deleted implementation, which the library now owns. PatchFieldValidationTest is kept as JsonNullableValidationTest because it proves our own extractor wiring: absent evaluates no constraints, present-null fires @notblank but not @SiZe. Full suite 3185 tests, 0 failures; the 38-test drop is exactly the two deleted classes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(members): honour the PATCH tri-state and stop a null ageRange from 500ing Two bugs the JsonNullable swap made addressable, both previously masked. Members collapsed the tri-state: UpdateMemberRequestMapper mapped absent and explicit-null alike to plain null, and Member.update read null as "leave alone". No optional member field could be cleared through the API, though the spec had always said an explicit null clears. Events honoured the tri-state; Members did not. Member.UpdateMember now carries JsonNullable for the optional fields, so the distinction survives into the domain. The required PersonalInformation fields stay plain — firstName, lastName, dateOfBirth and gender have no cleared state. A compact constructor normalises a plain null to undefined, which keeps the 27 existing builder call sites working without naming every field. Clearing is validated, not blindly applied: validateContactInformation, validateGuardianForMinors and validateBirthNumberNationality all run against the resolved values, so clearing an email with no guardian to cover it, a minor's guardian, or a Czech national's birth number is still rejected. The birth-number audit event now compares the applied value against the previous one, so it fires on a clear and stays quiet when the submitted value is unchanged. UpdateTrainingGroupRequest.ageRangeDomain() dereferenced a present null inside map — which applies the mapper on presence, not on nullness — turning the domain's Assert.notNull rejection into an NPE and a 500. It now forwards the null so the intended 400 surfaces. Spec descriptions updated to say which fields can and cannot be cleared, and the stale PatchField prose replaced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(openapi): generate the members and training-group PATCH DTOs from the spec UpdateMemberRequest (17 components) and UpdateTrainingGroupRequest (3) were the last request DTOs held back from codegen. They are now generated, leaving only UpdateEventRequest hand-written. The generator cannot supply the wrapper on its own. openApiNullable keys off `nullable`, which openapi-generator 7.18.0 ignores in OpenAPI 3.1 mode — checked against both `nullable: true` and the 3.1 spelling `type: [x, "null"]`, and neither produced a JsonNullable. So the wrapper comes from a new x-klabis-patch-field extension consumed by pojo.mustache, following the x-klabis-not-blank precedent, and openApiNullable stays off. Properties are inlined rather than sharing the named PatchField* schemas, which are deleted. Seven members properties are plain strings with different limits — email 255, chipNumber 50 plus a digit pattern, firstName 100 — and one shared schema can only carry one set of them. Annotation fidelity was diffed component by component against the deleted records: all 18 survive, including every @HasAuthority(MEMBERS_MANAGE). The only additions are @Valid markers the stock beanValidation partial puts on non-primitives, matching what RegisterMemberRequest already carries. `required: [firstName, lastName]` is dropped. On a PATCH body it would have forced both fields into every request, which contradicts partial updates and was never enforced by the hand-written record. ageRangeDomain() and the UUID-to-MemberId conversion move into TrainingGroupController, keeping the null-forwarding so an explicit null still reaches the domain's Assert as a 400 rather than NPEing into a 500. PatchRequestWrapperArchitectureTest guards the invariant that every component stays wrapped: RequestBodyFieldAuthorizationAdvice skips anything that is not, so an unwrapped field loses its authorization check silently. Custom constraint messages are lost to Bean Validation defaults; the tests already asserted the defaults. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(openapi): use the generator's own nullability instead of a custom extension The x-klabis-patch-field extension added in the previous commit was unnecessary. openApiNullable does work — the earlier conclusion that OpenAPI 3.1 nullability was broken came from testing it with our custom pojo.mustache still active, which ignores isNullable and prints the bare type. The generator had been setting the flag correctly all along. Two things were actually needed: openApiNullable=true, and the 3.1 spelling `type: [x, "null"]`. The 3.0 `nullable: true` keyword really is ignored in a 3.1 document, which is what the first experiment kept hitting. pojo.mustache now mirrors the stock template's branch on openApiNullable + isNullable, so the record form stays in step with what upstream does for classes. gender is the one exception and keeps allOf: the generator strips property-level vendor extensions from a composed schema, so the nullable oneOf would have cost it x-klabis-authority and made an admin-only field writable by anyone. It gives up nothing — gender has no cleared state, so the wrapper was never used. PatchRequestWrapperArchitectureTest records the exception and asserts the field still declares the authority it was excepted for, so the allow-list cannot quietly become a hole. Generated output is otherwise identical, and the custom extension is gone from the specs and the bundler's allow-list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(skill): document PATCH nullability in klabis-api-spec The skill said nothing about PATCH bodies, so anyone adding one would reach for `nullable: true` — the 3.0 keyword, silently ignored in these 3.1 specs. The endpoint would then quietly lose the ability to tell "absent" from "clear this field", with no warning from anywhere. Adds the 3.1 spelling for both scalar and $ref properties, the rule that `oneOf` strips property-level x-klabis-* (and why keeping the authority beats keeping the wrapper), and the JsonNullable.map trap that turns an intended 400 into a 500. Four matching anti-patterns. Also records, on the "inventing a new extension" entry, that a generator feature must be tested against stock templates before being declared broken — an x-klabis-patch-field extension was built and then removed once openApiNullable turned out to work, the earlier verdict having come from testing with our own pojo.mustache, which ignored the flag. Every claim checked against the code rather than from memory. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(skill): correct the oneOf extension-stripping rule Re-checked the previous commit's claims by running them rather than by grepping for their presence, and one was wrong. I had written that scalar unions keep their extensions "and only compositions lose them", implying a scalar `oneOf` is safe. It is not: rewriting birthNumber as `oneOf: [{type: string}, {type: 'null'}]` keeps the wrapper but drops @HasAuthority, exactly as a $ref branch does. The stripping is a property of the oneOf keyword itself, not of what it contains — `type: [x, 'null']` is what keeps both. The $ref case is a dilemma only because a $ref has no union form. Also verified by execution rather than assertion: map runs the mapper on a present null and skips it when absent, orElse returns a present null instead of falling back, allOf + nullable yields the bare type, and the three scalar-union properties keep authority and wrapper together. Adds a warning found while checking the response-side advice: several response schemas still carry a leftover `nullable: true`, inert only because the 3.0 keyword is ignored. Converting one to the 3.1 spelling would wrap a response DTO. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(openapi): generate the event PATCH DTO from the spec UpdateEventRequest was the last hand-written PATCH request. Generating it needed two codegen hooks that did not exist: - x-klabis-url, mirroring x-klabis-not-blank/x-klabis-past, since OpenAPI has no keyword for @url. It must not be paired with `format: uri`, which switches the Java type to java.net.URI that @url cannot constrain. - x-klabis-class-constraint, a schema-level hook naming a class-level annotation. A generated record has no method bodies, so the cross-field @AssertTrue isDeadlinesOrdered() could not survive migration; it becomes @DeadlinesOrdered, which reads `deadlines` reflectively and so serves the JsonNullable-wrapped PATCH shape as well as a plain one. The nested CategoryRequest/RankingRequest/EntryFeeRequest records are dropped in favour of the spec's existing top-level schemas, and the mapper now converts wire types (UUID, Set<UUID>) to domain types itself. CreateEventRequest stays hand-written: it carries domain conversion methods EventController calls directly. Its own nested EntryFeeRequest is removed, though — it shadowed the newly generated top-level record of the same name in the same package, which compiled only by luck of scoping rules. * feat(openapi): generate the event create DTO from the spec CreateEventRequest was the last hand-written request DTO. Its blocker was never the cross-field @AssertTrue — @DeadlinesOrdered already covers that — but the domain conversion methods it carried (toRegistrationDeadlines, toCategories, CategoryRequest.toDomain, EntryFeeRequest.toMoney) that EventController called directly, which a generated record cannot have. Those move to CreateEventRequestMapper, mirroring the update path. The four conversions both mappers need are shared via EventRequestConversions rather than duplicated. Generating the record also makes @Valid cascade into categories and fees for the first time — the hand-written one had no @Valid anywhere, so a blank category name reached the domain and failed Assert.hasText as a 500, and a negative fee amount was never checked at all. Both are now 400s; the two new EventControllerTest cases pin that. * Updated tests configuration to use SyncTaskExecutor for predictable test outcomes (#307) * test: run async listeners synchronously in tests (SyncTaskExecutor) * test: remove local SyncTaskExecutor configuration from MemberLifecycleE2ETest (use global test config) --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…son (#308) * feat(openapi): make the hand-written spec the source of klabis-full.json openapiBundle now produces docs/openapi/klabis-full.json, which the frontend generates its TypeScript types from. Removes copyGeneratedOpenApiSpec (springdoc -> klabis-full.json) and openapiDriftCheck, whose job was comparing the two documents while both existed. The springdoc plugin stays, but only to dump what the running app serves into gitignored generated/; nothing in the build depends on it. The task writes by default now instead of only validating, since it is the sole producer of the file. -PopenapiCheck validates without writing; -PopenapiOut redirects; passing both fails rather than silently ignoring the latter. Effect on the published document: 6 Actuator paths drop out (springdoc auto-discovered them and they do not belong in a hand-written API spec) and 18 dead PatchField* schemas go with them. No API operation is lost. Frontend halTypes.ts grows 12 -> 129 exported types, pre-existing drift catching up. Also fixes a field-authorization hole this exposed. UpdateMemberRequest.gender was specified as allOf + x-klabis-authority, which generated a bare Gender rather than JsonNullable<Gender> — and RequestBodyFieldAuthorizationAdvice skips every component that is not JsonNullable, so its @HasAuthority(MEMBERS_MANAGE) was never evaluated and any caller could change a member's gender. The allOf was chosen to dodge oneOf stripping the extension; it keeps the extension but costs the wrapper, which defeats the check just as thoroughly. Inlining the enum keeps both, at the cost of a schemaMappings entry for the generated UpdateMemberRequest_gender name. Wire semantics are unchanged: absent and explicit-null both still mean "leave alone" (PersonalInformation rejects a null gender outright). Only the authorization check changes, from bypassed to enforced. The @disabled test in UpdateMemberApiTest was recording this as a prod-code gap; it is enabled, narrowed to gender, and passes. PatchRequestWrapperArchitectureTest loses its UNWRAPPED_BY_DESIGN exception — which encoded the same wrong assumption, asserting the annotation was present rather than reachable — and now pins the exact set of privileged components per PATCH DTO. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(openapi): give the fee-group operation a unique operationId Two modules declared operationId getGroup — GET /api/groups/{id} in groups and GET /api/membership-fee-groups/{id} in membershipfees. OpenAPI requires the id to be unique across the document, and haltypes.mjs names its exported TypeScript types after it, so the collision emitted GetGroupResource and GetGroupRels twice and failed the frontend build. The duplicate predates the cutover; making klabis-full.json spec-derived is what first propagated it into the generated types. membershipfees takes the new name because groups' getGroup belongs to a consistent listGroups/createGroup/updateGroup/deleteGroup family. Renaming an operationId also renames the generated *Api method, so this covers the @OverRide in MembershipFeeGroupController and three methodOn call sites. The domain port's own getGroup is untouched — it is not part of the API surface. The bundler now rejects duplicate operationIds, naming both offending operations, so this fails at openapiBundle rather than in CI. Also corrects two comments claiming this operation was excluded from generation. It is not; only its response schema is documentation-only, mapped down to the bare payload so _embedded stays out of the Java return type. No wire-contract change: HAL rel names and paths are identical, only the operationId and the TypeScript type names derived from it move. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(members): cover the gender authority check with a test that can fail Restores the four-field body in shouldRejectUpdateAdminOnlyFieldsWithoutAdmin, per review. Narrowing it to gender was not a simplification but a loss: the caller in that test is neither the owner nor an admin, so its 403 comes from the endpoint's own @HasAuthority(MEMBERS_MANAGE) before any field is read. Verified by sending only chipNumber, which carries no field authority at all — still 403. The test never exercised field-level authorization, so trimming it to the one privileged field was pointless. That left the gender fix untested. The endpoint is annotated @HasAuthority(MEMBERS_MANAGE) *and* @OwnerVisible, so per-field checks decide something for exactly one caller: the owner, who passes the endpoint but holds no authority. Adds that pair to MemberSelfEditTests — own gender is refused, own chipNumber goes through. Both directions verified against the spec: dropping x-klabis-authority while keeping the wrapper fails the gender test, and restoring the allOf does not even compile, because the mapper expects a JsonNullable. Also corrects "writable by anyone" in the architecture test's javadoc and in two places in the klabis-api-spec skill. The endpoint still gates strangers; it is the owner who silently gains write access. More importantly the skill was recommending exactly the construct that caused this — "keep allOf + the extension and give up the wrapper" — which protects nothing, since the advice skips unwrapped components. It now says to inline the type and map <Parent>_<property> in schemaMappings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* docs(openapi): move suspendMember 409 contract into the spec The springdoc annotations on the controllers duplicate the spec, and the plan is to delete them. Auditing what they say against the bundled document first turned up one place where the spec was not merely thinner but wrong: suspendMember's 409. MembersExceptionHandler answers both suspension blockers with a bespoke JSON record — LastOwnerWarning (which groups need a new owner) or OutstandingDebtWarning (balance plus a link to the account) — while the spec pointed at the shared Conflict response, which promises application/problem+json and a ProblemDetail. A client generated from that would have mis-parsed every blocked suspension. Also adds resumeMember's missing description and the sole-owner blocker to suspendMember's. The rest of the annotation text needs no migration: the "(requires MEMBERS:MANAGE)" style summaries are already carried structurally by x-klabis-authority, every path/query parameter the controllers document is present in the spec with a description, and the CSV accommodation list is modelled as a text/csv variant of getAccommodationList rather than a second operation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NAsjrEgsny8rxTyPLq3sBB * refactor(openapi): generate springdoc annotations from the spec The 26 controllers implementing a generated *Api each hand-maintained @Operation/@ApiResponse/@Parameter/@Tag/@securityrequirement that restate what docs/openapi/spec/ already says. Flipping documentationProvider from "none" to "springdoc" makes the generator emit them onto the interface instead, so /v3/api-docs becomes spec-derived like klabis-full.json already is, and the duplicates go. Springdoc does resolve annotations through the interface: the override inherits the declaration that carries them. The skill text claiming otherwise ("springdoc scans the concrete class") was never tested, and led to 616 lines of hand-written documentation that could only drift. Measured by generating /v3/api-docs before and after: 117 operations both times, none lost. Summaries 82 -> 117, descriptions 51 -> 111, parameter descriptions 95 -> 133, no response description lost. The generated set also carries security, tags, @content schemas and @parameter(hidden = true) on the injected arguments, none of which the hand-written ones had. Two apparent losses are springdoc inaccuracies the spec corrects: a bogus 200 "OK" on ResponseEntity<Void> endpoints, now the real 201/204; and a 410 attached to every operation of PasswordSetupController because it sat on an @ExceptionHandler, including requestNewPasswordSetupToken, which throws TokenValidationException and can never return 410. The blocker was that documentationProvider renders each response's baseType into @Schema(implementation = <baseType>.class), and the envelope schemaMappings target generic types — Collection<X>.class is not legal Java. openApiModule now drops the whole content block for those responses so springdoc falls back to the return type, which still carries the type argument. Erasing to the raw type or leaving a bare @content instead both degrade 39 response schemas; only removing the block keeps PageMemberSummaryResponse and the array shapes. The regex is registered via inputs.property because a doLast body is not part of a task's cache fingerprint and GenerateTask is @CacheableTask. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NAsjrEgsny8rxTyPLq3sBB --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
#310) * refactor(openapi): emit @HasAuthority from api.mustache, not the bundler x-klabis-authority on an operation used to be rewritten during bundling into x-operation-extra-annotation carrying a fully-qualified Java string. The published klabis-full.json therefore stated each endpoint's authority twice: once in the spec's own vocabulary, and once as an annotation derived from it. api.mustache now reads the key directly, the same way pojo.mustache already does for schema properties, and applyOperationAuthorityAnnotations is deleted. x-operation-extra-annotation drops from 71 occurrences to 11 — the remaining ones all belong to x-klabis-owner-visible, which spans two nodes (operation plus the named parameter) and still needs a rewrite. The template is a verbatim fork of the stock JavaSpring api.mustache, verified byte-identical before editing, with one section added. Nothing detects drift against upstream, so both the file header and docs/openapi/spec/README.md carry the re-diff instruction for generator upgrades. Generated *Api.java output was diffed before and after: the set of emitted Klabis annotations is byte-identical (66 @HasAuthority, 11 @OwnerVisible, 11 @OwnerId). Only @OwnerVisible's position relative to @HasAuthority changes — they were one multi-line string, now two separate annotations. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NAsjrEgsny8rxTyPLq3sBB * refactor(openapi): emit @OwnerVisible/@OwnerId from the templates x-klabis-owner-visible named the parameter carrying the owner ID, and the bundler synthesised both annotations from that one key: x-field-extra-annotation on the parameter, x-operation-extra-annotation on the operation. Because parameters are shared by $ref — AccountMemberIdParam serves six operations — it also had to inline the named one so @OwnerId reached only the opted-in operation. None of that is necessary: @OwnerId is inert on its own. Both readers (HasAuthorityMethodInterceptor.checkOwnership and RequestBodyFieldAuthorizationAdvice) only consult it for a method or field already marked @OwnerVisible, so it can sit on the shared parameter and the six operations that never opt into ownership are unaffected. Each half now sits on its own node and is read by its own template: x-klabis-owner-visible: true on the operation (api.mustache), x-klabis-owner-id: true on a path parameter (pathParams.mustache, forked for this). Neither template can see the other, so validate.mjs takes over the pairing invariant: an operation declaring owner-visible needs exactly one owner-id parameter — zero denies instead of resolving ownership, and two would silently resolve against whichever the generator emitted first. It must also be a path parameter. Only pathParams.mustache has a branch for the key, so on a query or header parameter it would be dropped without a word, reproducing the unenforced-@OwnerVisible failure one step later. That check also covers page/size/sort, which x-spring-paginated folds into a single Pageable argument. With this, both derived extensions are gone from the published contract: x-operation-extra-annotation 71 -> 0, x-field-extra-annotation 11 -> 0. The bundler is now a plain merge, 141 lines lighter. @OwnerId rises 11 -> 17 in the generated interfaces; the six additions are the inert shared-parameter cases (charge, deposit, reverse, getMember, resumeMember, suspendMember). @HasAuthority (66) and @OwnerVisible (11) are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NAsjrEgsny8rxTyPLq3sBB * test(events): stop registering members on hard-coded event dates Event.registerMember() rejects a registration made on or after the event date, comparing against LocalDate.now(). Three tests built their event on a fixed 2026 date and then registered a member on it, so each was a dated bomb: shouldLogWarningWhenSyncRemovesCategoriesWithRegistrations used 2026-08-01 and went off today; the two EventTest cases used 2026-09-10 and would have followed on 10 September. They now derive the date from LocalDate.now(), which is what the rest of the suite already does (134 call sites) and what EventTest's own DEFAULT_DATE was already for. The other fixed dates in these files are left alone. Only registerMember and its siblings reach a LocalDate.now() comparison — dates in June and July 2026 have passed and their tests still pass — so the fix is scoped to the operations that actually cross it, found by pairing every registerMember / editRegistration / unregisterMember call with the event date in scope. EventJdbcRepositoryTest already used relative dates throughout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NAsjrEgsny8rxTyPLq3sBB --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
) * feat(openapi): make each module spec a standalone OpenAPI document The nine module files had only `paths:` and `components:` at the top level, so none of them could be opened in Swagger UI or Redoc on its own — you had to bundle first to look at any one module's API. Each now carries `openapi`, `info` and `servers`, plus `components.securitySchemes`. That last one is not decoration: operations carry `security: [KlabisAuth: [...]]`, and a security requirement naming a scheme the document does not define is a hard error. `members.yaml`, `groups.yaml` and `event-types.yaml` now pass `redocly lint` clean. The bundler output is byte-identical — modules are pulled in through a `#/paths/...` pointer, so nothing outside `paths` and the referenced `components` is ever read. That is also what makes the duplication dangerous: a module drifting from the root would keep bundling cleanly and only mislead whoever opened that one file. `validateModuleDocuments` pins each module's `openapi`, `info.version` and `securitySchemes` to klabis.yaml's, and derives its module list from the paths klabis.yaml routes rather than globbing the directory, so a scratch .yaml left there is not mistaken for a module. `securitySchemes` stays inlined in klabis.yaml rather than shared from `_shared/`: a root component that only $refs another file collapses to a self-referencing `$ref: '#/components/securitySchemes/KlabisAuth'`, because a ref into `#/components/` is localized rather than expanded. Left alone as pre-existing and out of scope: 29 `nullable: true` keys (OpenAPI 3.0 syntax in 3.1 documents) and finance.yaml referencing PageParam/SizeParam/SortParam, which are defined in common.yaml. Both predate this change — klabis.yaml itself lints identically before and after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NAsjrEgsny8rxTyPLq3sBB * fix(openapi): OpenAPI 3.1 nullable syntax and finance.yaml's undefined param refs Two problems the previous commit's redocly lint run surfaced and deliberately left out of scope, now fixed. `nullable: true` is OpenAPI 3.0 syntax, invalid in these 3.1.0 documents. Rewritten to the 3.1 union spelling `type: [X, 'null']` across 28 occurrences (oris.yaml, calendar.yaml, membershipfees.yaml, events.yaml). None of the 11 affected schemas generate Java from the spec today — confirmed by diffing regenerated UpdateEventRequest (the one schema in this set that DOES generate) against both old and new spec content: byte-identical except the @generated timestamp. openapi-typescript already treated `nullable: true` as `| null` before this change too, so the only functional frontend diff is the parameter added below. One occurrence needed a different rewrite: MemberFeeSummaryResponse.currentGroup had `allOf: [$ref] + nullable: true`, which doesn't compose the way it looks like it should. Rewritten to `oneOf: [{$ref}, {type: 'null'}]` — confirmed openapi-typescript still emits a plain `CurrentGroupResponse | null`, not a wrapper. finance.yaml referenced PageParam/SizeParam/SortParam as local $refs, but they were defined in members.yaml's components.parameters — invisible as long as the bundler merged everything into one namespace, but exactly what breaks a module meant to stand alone. PageParam/SizeParam are generic paging params also duplicated in events.yaml, so both now live in new _shared/pagination.yaml and both files' local copies are gone. SortParam is NOT centralized: its description enumerates resource-specific sortable fields, and finance.yaml was $ref-ing members.yaml's version verbatim — a transactions endpoint documented as sortable by firstName/lastName. Gave it its own TransactionSortParam describing TransactionResource's actual fields instead. Verified: klabis-full.json diff is exactly the 29 leaf changes above plus the one parameter substitution — confirmed via structural diff, not line count. Backend --compile-only succeeds; full suite confirmed via JUnit XML directly (3200 tests, 0 failures, 0 errors, 14 skipped, matching the pre-change baseline). Frontend `tsc -b && vite build` succeeds; klabisApi.d.ts regenerated. redocly lint: finance.yaml goes from 3 no-unresolved-refs errors to fully valid; the four nullable-bearing files lose their struct errors. Reviewed by developer:code-reviewer: no blocking or warning findings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NAsjrEgsny8rxTyPLq3sBB --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ture/api-spec-first
…312) * updated project structure for IDE * chore(openapi): remove klabis-full.json from git, generate on demand The bundled spec is a pure build artifact of docs/openapi/spec/ and required developers to remember a manual regenerate-and-commit step on every spec change. Backend codegen already bundled fresh into build/generated/ on every build, so this only affects frontend type generation and API docs publishing. - gitignore klabis-full.json instead of committing it - npm run openapi now bundles the spec first, so a single command is enough instead of the previous two-step gradlew+npm sequence - publish-api.yaml triggers on docs/openapi/spec/** changes and bundles the spec itself before publishing Swagger UI - update docs that described the file as committed
Proposal-only (spec-free schema) for removing the last 33 manual schemaMappings/importMappings entries and the per-module models allow-list from backend/build.gradle.kts, so backend codegen is driven entirely by the API specs. Covers domain enum substitutions, the SuspensionBlockedWarning oneOf case, Page<T> pagination mappings, cross-package application-layer types, envelope mappings for no-application/json-sibling operations, and nested Java class mappings (PaymentRuleResponse, OrisEventSummary) — the last of which surfaced a real blocker during investigation (OpenAPI 3.1's nullable spelling is indistinguishable from PATCH tri-state semantics for the generator), documented as an open question for design.
…s (all modules) (#313) * refactor(openapi): give HAL responses an application/json sibling for backend codegen Every HAL/HAL+FORMS response in the spec referenced an envelope schema (EntityModel*/PagedModel*/CollectionModel*), which backend codegen then had to manually redirect back onto the plain Java payload type via a large schemaMappings table in build.gradle.kts. Add a plain application/json content entry to each such response, and have bundleSpecForCodegen strip each HAL content type's schema (keeping the key so Spring's `produces` still advertises it) whenever an application/json sibling exists. Backend codegen now reads the bare payload schema directly, so most envelope schemaMappings entries for a migrated module become unnecessary. Piloted on the members module only. klabis-full.json (openapiBundle, what the frontend reads) is unaffected by the new --strip-hal flag. * refactor(openapi): application/json sibling for event-types module Adds a plain application/json content entry alongside the HAL/HAL+FORMS response for listEventTypes and getEventType, following the members module pilot. listEventTypes needed a new named EventTypeDtoList schema (rather than an inline array) so schemaMappings could retarget it onto Collection<EventTypeDto> — an inline array has no schema name to map from and the generator infers List<EventTypeDto> directly from it, bypassing the mapping and changing the controller's return type. * docs(openapi): generalize collection schemaMappings guidance beyond Page<T> The event-types module migration hit the same inline-array pitfall for a plain Collection<T> return type, not just Page<T> — the skill only documented the paginated case. Broaden it to any collection response whose Java type isn't bare List<T>. * refactor(openapi): application/json sibling for finance module Every HAL response in finance.yaml now also declares a plain application/json content entry, so backend codegen can resolve the Java payload type directly from the spec instead of the schemaMappings table. listTransactions needed a named TransactionResourceList array schema (mapped to Page<TransactionResource>) since an inline array would let the generator infer List<T> and silently break the interface's return type. * refactor(openapi): application/json sibling for events module Every HAL response in events.yaml now also declares a plain application/json content entry, so backend codegen can resolve the Java payload type directly from the spec instead of the schemaMappings table. listEvents, listRegistrations, listPresets and getAccommodationList needed named array schemas (EventSummaryDtoList, RegistrationSummaryDtoList, CategoryPresetDtoList, AccommodationListItemDtoList) since an inline array would let the generator infer List<T>/bypass the Page<T>/Collection<T> mapping. getEvent deliberately keeps only its HAL/HAL+FORMS content entry: the bundler alphabetizes content keys, so an application/json sibling would always sort ahead of application/prs.hal-forms+json and become the schema haltypes.mjs picks for GetEventResource — silently dropping the _embedded.registrationDtoList typing that getEvent's response actually carries (contributed by the controller via HalResponseContext.embed, not the generated Java return type). * fix(openapi): halTypes.ts must type *Resource off the HAL envelope, not application/json collectHalResources picked its schema from Object.values(content)[0] — whichever content-type key came first. Bundled documents sort keys alphabetically, so the new application/json sibling (added for backend codegen, see klabis-api-spec skill) always won over application/prs.hal-forms+json. Every *Resource type for a collection/paged endpoint silently lost its _embedded/page typing across the members, event-types, finance, and events module migrations — caught while migrating events, where the schema swap would have also dropped getEvent's embedded registrations typing. Now explicitly prefers application/prs.hal-forms+json / application/hal+json, falling back to any content schema only if neither carries one. * refactor(openapi): application/json sibling for calendar module Adds a plain application/json content entry alongside the HAL/HAL+FORMS response for listCalendarItems, getCalendarItem, getTokenState and generateToken, following the members module pilot. listCalendarItems needed a new named CalendarItemDtoList schema (rather than an inline array) so schemaMappings could retarget it onto Collection<CalendarItemDto> — an inline array has no schema name to map from and the generator infers List<CalendarItemDto> directly from it, bypassing the mapping. The iCal feed endpoint (text/calendar) and createCalendarItem/updateCalendarItem/ deleteCalendarItem (bodyless 201/204) are untouched. * refactor(openapi): application/json sibling for membershipfees module Adds a bare-payload application/json content entry alongside every HAL/HAL+FORMS response in the membershipfees spec, letting backend codegen resolve Java return types directly instead of through schemaMappings. Collection endpoints (Collection<T> return types) get a named top-level array schema per klabis-api-spec skill guidance. getFeeGroup keeps no application/json sibling, mirroring events' getEvent precedent: a bare MembershipFeeGroupResponse sibling would win frontend type resolution over EntityModelMembershipFeeGroupResponseWithMembers and silently drop _embedded.members from the generated GetFeeGroupResource type. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NAsjrEgsny8rxTyPLq3sBB * refactor(openapi): application/json sibling for groups module Add application/json content siblings to the four collection GET responses (listFamilyGroups, listGroups, getPendingInvitations, listTrainingGroups) across all three groups Gradle registrations (groupsFamily/groupsFree/ groupsTraining), each backed by a new named top-level array schema since the controllers return Collection<T>, not List<T>. getFamilyGroup/getGroup/getTrainingGroup are deliberately left HAL-only: their payload records carry List<EntityModel<X>> fields (parents/members/ owners/trainers each with per-item _links), the same getEvent precedent already documented in the module header comment. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NAsjrEgsny8rxTyPLq3sBB * refactor(openapi): application/json sibling for common module getUserPermissions gets a plain-payload sibling like every other migrated module. rootNavigation/dashboard are deliberately skipped: RootModel and DashboardModel are empty marker records, and adding a third content-type entry (application/json alongside the existing application/hal+json and application/prs.hal-forms+json) made the generator's return-type resolution collapse to ResponseEntity<Void>, breaking the controllers' overrides. Confirmed by generating with two vs. three content types on the same schema. PermissionControllerTest's HATEOAS assertions needed an explicit Accept header — getUserPermissions now produces application/json first, so content negotiation without a header could pick the bare payload over HAL-FORMS. * docs(openapi): document gotchas found while migrating all 8 modules Adds the lessons learned rolling the application/json-sibling convention out to event-types, finance, events, calendar, membershipfees, groups, and common (oris needed no change — it was never HAL to begin with): - Only add the sibling where a HAL envelope actually exists. - haltypes.mjs must type *Resource off the HAL content type, not whichever content type sorts first (already fixed in code; documents why). - A response already carrying two content types on the same schema (application/hal+json + application/prs.hal-forms+json) can collapse to ResponseEntity<Void> if a third is added — skip the sibling there. - MockMvc tests asserting _links/_templates need an explicit Accept header once an endpoint also produces application/json. * refactor(openapi): eliminate event-types schemaMappings entirely Empirically confirmed (docs/technicalAnalysis/openapi-generator-list-types.md): the spring generator (7.18.0) never generates a wrapper class for a type: array schema, named or inline — it always emits List<T> directly, with no mapping needed at all. The "List is a reserved schemaMappings target" pitfall only applies to explicitly mapping something else onto the string "java.util.List"; leaving an array schema unmapped was safe the whole time. EventTypeController.listEventTypes now returns List<EventTypeDto> (was Collection<EventTypeDto> — the underlying value was already a List via .toList(), only the declared type changes) to stay covariant with the generated interface. EntityModelEventTypeDto's mapping is also gone: its schema is emptied for backend codegen by stripHal.mjs, so schemaMappings never needs to see it, and the application/json sibling (EventTypeDto) resolves with no mapping since the schema name already matches the class. This is the reference pattern for eliminating schemaMappings module by module — Page<T> mappings still need to stay (no array shape carries pagination metadata), but every List<T>/single-resource mapping is now provably unnecessary. * docs(openapi): correct schemaMappings guidance — List<T> never needs a mapping Supersedes the earlier (wrong) claim that an inline type: array schema "has no schema name to map from" and needs a named schema + schemaMappings entry to avoid some broken fallback. Empirically verified against the pinned generator (7.18.0, see docs/technicalAnalysis/openapi-generator- list-types.md): a type: array schema, named or inline, always generates List<T> directly with no wrapper class — no mapping is ever needed for it. Only Page<T> (pagination metadata no array shape can carry) still needs a named array schema + mapping. A controller returning Collection<T> should be migrated to List<T> instead of kept mapped, since there's no generator-native way to produce Collection<T> from an array schema. * refactor(openapi): eliminate finance module single-resource schemaMappings Empirically confirmed (docs/technicalAnalysis/openapi-generator-list-types.md): EntityModelMemberAccountResource and EntityModelTransactionResource are HAL envelope schemas emptied by stripHal.mjs, so their application/json siblings (MemberAccountResource, TransactionResource) resolve directly to the matching hand-written record with no mapping needed — schema name already equals the Java class name. PagedModelEntityModelTransactionResource -> Page<T> and TransactionResourceList -> Page<T> both stay: listTransactions is genuinely paginated (x-spring-paginated: true), and removing TransactionResourceList's mapping was verified to degrade the generated FinanceApi.listTransactions from Page<TransactionResource> to List<TransactionResource>, since the plain-JSON sibling's inferred type wins resolution for the operation. No array shape carries pagination metadata, so both mappings targeting Page<T> are required. No controller migration needed — finance has no plain Collection<T>-returning endpoint. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * refactor(openapi): rename TransactionResourceList to TransactionResourcePage The array-shaped application/json sibling for listTransactions is named after its Java resolution target (Page<TransactionResource>, via schemaMappings), not its JSON wire shape (a plain array) — "...List" reads as if it should resolve to List<T> with no mapping needed, which is exactly the pattern eliminated elsewhere in this module (see event-types). Pure rename: still type: array + schemaMappings onto Page<T>, same mechanism, no behavior change. Confirmed via regenerated FinanceApi.java (listTransactions still returns Page<TransactionResource>) and compileJava. * refactor(openapi): eliminate events module schemaMappings Per the corrected pattern (docs/technicalAnalysis/openapi-generator-list-types.md): a type: array schema, named or inline, never needs a schemaMapping — it always generates List<T> directly. Removed the now-unnecessary envelope mappings (EntityModelEventSummaryDto, EntityModelRegistrationDto, EntityModelCategoryPresetDto) and named-array-sibling mappings (AccommodationListItemDtoList, RegistrationSummaryDtoList, CategoryPresetDtoList) that were added under the disproven belief that a named array sibling needed retargeting. CategoryPresetController.listPresets, EventController.getAccommodationList and EventRegistrationController.listRegistrations now return List<T> instead of Collection<T> to match the generated interface — each already built a List internally via .toList(), only the declared return type changes. Kept: PagedModelEntityModelEventSummaryDto + EventSummaryDtoList (both, per the finance-module precedent — listEvents is genuinely paginated and removing either mapping degrades the generated type to List<T>), EntityModelEventDtoWithRegistrations (schema name differs from EventDto, and the _embedded block is contributed out-of-band via HalResponseContext.embed), and the four BulkSyncResult/BulkImportResult mappings (application-layer types whose package never matches infrastructure.restapi). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * refactor(openapi): eliminate calendar module schemaMappings listCalendarItems' Collection<T>/CalendarItemDtoList mappings existed only to preserve an incidental Collection<T> controller signature; migrate the controller to List<T> instead, which the generator produces natively from an array schema with no mapping needed. * refactor(openapi): eliminate membershipfees module schemaMappings Empirically confirmed: EntityModel*/CollectionModel* HAL envelope mappings and all *List named-array-schema mappings targeting Collection<T> are unnecessary once the application/json sibling is present — the bare payload schema either already matches its target class name (single resources) or resolves directly to List<T> (named array schemas, per docs/technicalAnalysis/openapi-generator-list-types.md). Migrated listTiers, listRules, listPublications, listGroupsForYear and listGroupRules from Collection<T> to List<T> return types to match. Two mappings stay, both for schema names that genuinely differ from their Java target: - PaymentRuleResponse -> MembershipFeeTierResponse.PaymentRuleResponse (nested class; the bare schema name alone would resolve to a bogus top-level class) - EntityModelMembershipFeeGroupResponseWithMembers -> MembershipFeeGroupResponse (getFeeGroup embeds a second, independently-shaped collection via HalModelBuilder that HalResponseContext can't express as one object; no application/json sibling exists for this operation, same precedent as events.yaml's getEvent) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * refactor(openapi): eliminate groups module schemaMappings Collection<T> list/collection endpoints across the three groups Gradle modules (listFamilyGroups, listGroups, listTrainingGroups, getPendingInvitations) had schemaMappings redirecting their named array siblings onto Collection<T>. Migrate the four controllers to List<T> instead and drop the now-unnecessary mappings, per the corrected List<T>-needs-no-mapping guidance. The three "get single group" envelope mappings (EntityModelFamilyGroupResponse/EntityModelGroupResponse/ EntityModelTrainingGroupResponse) stay: those operations have no application/json sibling (per-item _links on nested collections can't be expressed in a bare payload), so backend codegen still sees the full envelope schema. * refactor(openapi): eliminate common module schemaMappings EntityModelPermissionsResponse was redundant: getUserPermissions's application/json sibling already resolves PermissionsResponse directly by matching schema name, so only the bare PermissionsResponse mapping is needed. EntityModelRootModel/EntityModelDashboardModel mappings verified empirically still required — without them the generated return type collapses to the never-generated envelope class. * docs(openapi): review every remaining schemaMappings entry with reasoning Full audit of all 33 mapping entries left in backend/build.gradle.kts after the schemaMappings-elimination round, split into the pre-existing domain-enum substitution mechanism (8 entries, unrelated to HAL/JSON-split work) and the genuine envelope/response-shape redirections (19 + 5 extraImportMappings entries). Verified two previously-untested cases in this pass: Gender (enum category, representative case) and OrisEventSummary (nested-class category) — both fail to compile when removed, confirming they're genuinely required. Every remaining entry now has documented, verified justification. * refactor(openapi): move PaymentRuleResponse and OrisEventSummary to top-level classes Both were nested classes requiring a schemaMappings redirect since a bare OpenAPI schema name never auto-resolves to a nested class. Moving them to top-level files in the same package lets the generator resolve them directly, eliminating both mappings entries. * Revert "refactor(openapi): move PaymentRuleResponse and OrisEventSummary to top-level classes" This reverts commit 92308ad. --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…ture/api-spec-first
…only DTOs (#314) Several response-only schemas (OrisEventSummary, IcalTokenResponse, EventSyncEntry, EventImportEntry, RegistrationDto, AccommodationListItemDto, PaymentRuleResponse, FeeSelectionCampaignResponse, MemberInGroupResponse, MemberFeeChoiceResponse, MemberFeeSummaryResponse) used the `type: [x, 'null']` / oneOf-null spelling reserved for JsonNullable<T> PATCH tri-state semantics, even though none of them are PATCH request bodies. A plain optional property (absent from `required`) already expresses "may be null/absent" without the wrapper. Confirmed via audit that the only legitimate uses of the null-union spelling are the three actual PATCH bodies: UpdateMemberRequest, UpdateEventRequest, UpdateTrainingGroupRequest. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Escape backslashes in haltypes.mjs quote() (CodeQL: incomplete string escaping). Remove unused locals/params flagged by github-code-quality in CalendarController, UpdateEventRequestMapper, MembershipFeesRequestMapper and HalResponseBodyAdviceTest (the latter now actually uses the injected Pageable instead of a hardcoded one). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
| // Mirrors a generated @Validated *Api interface method whose @RequestParam carries constraints. | ||
| static class ValidatedApi { | ||
| @SuppressWarnings("unused") | ||
| public void validateToken(@NotBlank @Pattern(regexp = "^(?!\\s*$).+") String token) { |
| * implementation would find no body annotation and lose its input metadata. | ||
| */ | ||
| @RequestMapping(method = RequestMethod.PATCH, value = "/api/afford-test/iface-routed/{id}") | ||
| ResponseEntity<Void> updateIfaceRouted(@PathVariable UUID id, @RequestBody AffordanceTestRequest body); |
| * implementation would find no body annotation and lose its input metadata. | ||
| */ | ||
| @RequestMapping(method = RequestMethod.PATCH, value = "/api/afford-test/iface-routed/{id}") | ||
| ResponseEntity<Void> updateIfaceRouted(@PathVariable UUID id, @RequestBody AffordanceTestRequest body); |
| interface AffordanceApi { | ||
|
|
||
| @HasAuthority(Authority.MEMBERS_MANAGE) | ||
| ResponseEntity<Void> updateIfaceHasAuthority(UUID id, AffordanceTestRequest body); |
| interface AffordanceApi { | ||
|
|
||
| @HasAuthority(Authority.MEMBERS_MANAGE) | ||
| ResponseEntity<Void> updateIfaceHasAuthority(UUID id, AffordanceTestRequest body); |
The custom pojo.mustache fork replaced the stock class-emitting template with a record-emitting one but only wired the non-enum path into model.mustache. A top-level enum model (a RESOLVE_INLINE_ENUMS-promoted inline enum property left unmapped by schemaMappings, or a directly $ref'd enum schema) silently produced no type at all instead of a compile error. Restores the stock JavaSpring enumOuterClass partial for isEnum models, verbatim — an enum has no fields to carry x-klabis-* field-security extensions, so nothing Klabis-specific needs forking here. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…nums Now that model.mustache renders a real enumOuterClass for a top-level enum schema (previous commit), the generator can synthesize its own DTO-side enum per module instead of being pointed at the hand-written domain enum via schemaMappings. Removed the enum-only mapping entries for Gender, DeactivationReason, DrivingLicenseGroup, TrainerLevel, RefereeLevel (members) and EventStatus (events), and added explicit DTO<->domain conversions at the REST boundary (MemberMapper, UpdateMemberRequestMapper, MemberController, EventController) so the domain enums stay untouched and fully decoupled from the wire representation. Authority stays mapped: it's used pervasively outside the DTO layer (@HasAuthority annotations, security interceptors, JWT claims, OAuth2 scopes), so redirecting it would force Authority/DTO conversions into core security code for no benefit. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.