Skip to content
Open
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
f00326f
feat(events): add stable identity to event categories
dapolach Jul 24, 2026
d9c550d
feat(events): reference event category by stable id from registration
dapolach Jul 26, 2026
feb9c56
feat(events): pair ORIS categories by orisId on import and sync
dapolach Jul 26, 2026
75b4495
feat(events): carry structured categories in domain events
dapolach Jul 26, 2026
fa86ce9
refactor(events): address code review findings for category identity
dapolach Jul 26, 2026
9caeb61
fix(events): render category collection field as repeatable rows
dapolach Jul 26, 2026
6b2e823
docs(events): mark event-category-identity tasks complete
dapolach Jul 26, 2026
43fd43a
docs(events): sync specs and archive event-category-identity
dapolach Jul 26, 2026
6c3a2e5
openspec(halforms): unify custom field factory composition and collec…
dapolach Jul 27, 2026
e9f265b
refactor(halforms): add optional customFactory parameter to halFormsF…
dapolach Jul 27, 2026
af3d6ae
refactor(halforms): extract fieldFactory prop from HalFormsInputProps…
dapolach Jul 27, 2026
9e54c02
refactor(halforms): migrate field factories to composed custom field …
dapolach Jul 27, 2026
de3a792
docs(halforms): document field factory signature, composition helpers…
dapolach Jul 27, 2026
eb47cc5
docs(halforms): note fieldFactory is now an explicit HalFormsCollecti…
dapolach Jul 27, 2026
3734d92
docs(openspec): mark verification tasks complete for refactor-halform…
dapolach Jul 27, 2026
2a2b7a0
refactor(halforms): post-implementation cleanup for field factory uni…
dapolach Jul 27, 2026
7000bc3
chore(openspec): archive completed change refactor-halforms-field-fac…
dapolach Jul 27, 2026
94c68c9
refactor(halforms): reorder imports for consistency and add TODOs for…
dapolach Jul 27, 2026
4af64be
fix(halforms): unify multi-member field rendering; remove dead list t…
dapolach Jul 27, 2026
94672f4
fix(traininggroup): render trainers field as member picker
dapolach Jul 27, 2026
41c5df7
chore(openspec): archive completed change unify-multi-member-field-re…
dapolach Jul 27, 2026
84620c2
feat(traininggroup): introduce AgeRangeResponse for min/max age handling
dapolach Jul 27, 2026
70ad782
refactor(members): remove MemberResource interface, use MemberDetails…
dapolach Jul 27, 2026
88a6da9
feat(openspec): configure OpenAPI schemas to map domain IDs to UUIDs
dapolach Jul 27, 2026
3e470c3
build(openapi): add spec bundler and drift check for spec-first migra…
dapolach Jul 27, 2026
2c38465
feat(openapi): migrate members module to hand-written spec
dapolach Jul 28, 2026
41b2633
feat(openapi): generate frontend HAL types from x-hal-* extensions
dapolach Jul 28, 2026
1db60a1
feat(openapi): generate members response DTOs from the spec
dapolach Jul 28, 2026
e50beeb
fix(gradle): fixed gradle builds for local and github CI
dapolach Jul 28, 2026
fc03746
feat(hateoas): wrap plain payload DTOs into HAL models via ResponseBo…
dapolach Jul 28, 2026
a834fc4
feat(openapi): generate members and event-types APIs from the spec
dapolach Jul 29, 2026
77e5001
feat(openapi): migrate finance and calendar modules to spec-first
dapolach Jul 29, 2026
2a7c7db
feat(openapi): migrate remaining modules to spec-first (#304)
dapolach Jul 30, 2026
e902430
refactor: route HAL affordances at generated *Api interfaces (#305)
dapolach Jul 30, 2026
a5b2e18
refactor(common): replace PatchField with JsonNullable (#306)
dapolach Jul 31, 2026
cf943e7
feat(openapi): make the hand-written spec the source of klabis-full.j…
dapolach Jul 31, 2026
25b4e91
docs(openapi): move suspendMember 409 contract into the spec (#309)
dapolach Aug 1, 2026
4f87404
refactor(groups): remove files generated to wrong path
dapolach Aug 1, 2026
7ef28a0
chore(hookify): disable block-direct-tests hook locally
dapolach Aug 1, 2026
ce4ad32
chore(frontend): updated frontend bundle
dapolach Aug 1, 2026
980f46e
refactor(openapi): read x-klabis-* authorization keys in the template…
dapolach Aug 1, 2026
cee248e
updated project structure for IDE
dapolach Aug 1, 2026
91a4fb8
feat(openapi): make each module spec a standalone OpenAPI document (#…
dapolach Aug 1, 2026
9c67773
Merge remote-tracking branch 'origin/feature/api-spec-first' into fea…
dapolach Aug 1, 2026
851e2e8
chore(openapi): remove klabis-full.json from git, generate on demand …
dapolach Aug 1, 2026
11ca1f0
docs(openapi): propose eliminating remaining manual schemaMappings
dapolach Aug 2, 2026
3421c2f
refactor(openapi): application/json sibling + eliminate schemaMapping…
dapolach Aug 2, 2026
2f19c3e
Merge remote-tracking branch 'origin/feature/api-spec-first' into fea…
dapolach Aug 2, 2026
4292843
fix(openapi): remove PATCH tri-state nullable spelling from response-…
dapolach Aug 2, 2026
4711c63
fix(review): address PR #300 CodeQL and code-quality bot findings
dapolach Aug 2, 2026
bc687f5
fix(openapi): add missing top-level enum branch to model.mustache
dapolach Aug 2, 2026
90e2baf
refactor(openapi): stop redirecting inline enum schemas onto domain e…
dapolach Aug 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .claude/agent-memory/frontend-developer/MEMORY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Frontend-developer memory

## Project

- [event-category-identity frontend iteration 2](project_event_category_identity_frontend.md) — no separate "Moje přihláška" component, orphaned category display pattern, hideEmptyColumns gotcha, openapi regen against live 8443 backend without touching docs/
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
name: event-category-identity-frontend
description: Frontend iteration 2 of event-category-identity — registration category is now an id-backed object, single shared registrations table doubles as admin view and member's own view
metadata:
type: project
---

OpenSpec change `event-category-identity` iteration 2 (backend done as of 2026-07-26): registration
requests use `categoryId` (UUID via `EventCategoryId {value}`), responses carry `category: {id, name} | undefined`
(absent/null = orphaned registration, category was removed from the event after the member registered).

**Key discovery: there is no dedicated "Moje přihláška" component.** The admin-facing
`RegistrationsTable` inside `frontend/src/pages/events/EventDetailPage.tsx` is the single shared
table both admins and the registered member see (member sees their own row via the same
`_embedded.registrationDtoList`, other rows/columns hidden per-field by backend authorization).
Don't build a separate "own registration" component — extend this one.

**HAL-FORMS `register`/`editRegistration` needed zero custom field code** — the affordance's
`categoryId` property comes with `options.inline` (value=id, prompt=name), and the existing generic
`HalFormsSelect` (`frontend/src/components/HalNavigator2/halforms/fields/HalFormsSelect.tsx`) already
renders inline options generically. Only the table's category *display* cell needed a change.

**Fix pattern for orphaned category display**: `TableCell column="category"` needs a `dataRender`
reading `(value as EventCategory | null | undefined)?.name ?? labels.ui.notProvided` — reused the
existing `notProvided: 'neuvedeno'` label rather than adding a new one for "unset category".

**`KlabisTable`'s `hideEmptyColumns` gotcha**: it hides a column if *every* row's raw value is
`null`/`undefined`/`''`. A test with only an orphaned row (`category: null`) and no other row with a
real category will hide the whole category column — the "neuvedeno" cell never renders in that case.
Tests (and real orphaned-registration scenarios) need at least one row with a real category present
so the column stays visible. See `frontend/src/pages/events/EventDetailPage.test.tsx` describe block
`'category cell display (event-category-identity)'`.

## Regenerating klabisApi.d.ts against a running backend

`docs/openapi/klabis-full.json` (source for `npm run openapi`) is generated by a Gradle task that
expects the backend on port **8080**, but local dev backend runs on **8443** (self-signed TLS). When
you need fresh types and only 8443 is up: `curl -sk https://localhost:8443/v3/api-docs -o <tmp>.json`
then `npx openapi-typescript <tmp>.json -o ./src/api/klabisApi.d.ts` directly — skip writing into
`docs/openapi/klabis-full.json` since that file lives outside `frontend/` and frontend-developer must
not touch files outside `./frontend`. Verify with `python3 -c "import json; json.load(open(...))"` that
the curl didn't truncate before trusting the regenerated types.
81 changes: 55 additions & 26 deletions .claude/skills/backend-patterns/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
name: backend-patterns
description: Backend implementation patterns. Use this skill proactively whenever implementing, modifying, or fixing any backend Java code in this project — including aggregates, domain commands, application services (ports), REST controllers with HATEOAS affordances (klabisLinkTo/klabisAfford), JDBC persistence (memento pattern, repository adapters), domain events and listeners, field-level authorization (@OwnerVisible, @HasAuthority, PatchField), or adding new modules. This is the authoritative source for how Klabis backend code should be structured.
user-invocable: false
version: 0.5.1
version: 0.6.0
---

# Klabis Backend Patterns
Expand Down Expand Up @@ -242,31 +242,51 @@ ResponseEntity<Void> updateMember(@PathVariable @OwnerId UUID id,

Field-level authorization on request DTO (`@HasAuthority`, `@OwnerVisible` on `PatchField<T>` components) is enforced by `RequestBodyFieldAuthorizationAdvice`. Single command path — no role-based branching in controller.

### HATEOAS — EntityModelWithDomain + Postprocessor Pattern
### HATEOAS — Controllers Return Plain DTOs; HalResponseBodyAdvice Wraps Them

**Primary choice for creating `EntityModel` instances in controllers that load a domain aggregate.** Controllers focus on returning data; all link/affordance customization lives in a dedicated postprocessor that receives BOTH the DTO-shaped `EntityModel<T>` and the domain aggregate `D`.
**Canonical pattern for all new/migrated controllers.** Since the migration to spec-first OpenAPI generation, controller methods must return the plain JSON-payload type from the generated API interface (`ResponseEntity<SomeResponse>` / `ResponseEntity<Page<SomeResponse>>`) — the generator does not produce `EntityModel`/`PagedModel` return types. Hypermedia wrapping happens **after** the controller returns, via `HalResponseBodyAdvice` (a `ResponseBodyAdvice` in `com.klabis.common.ui`), driven by a request-scoped `HalResponseContext` that the controller populates with the domain object(s) behind the DTO.

**Controller — use `entityModelWithDomain(dto, domain)` instead of `EntityModel.of(dto)`:**
**Controller — return the plain DTO, stash the domain object(s) in `HalResponseContext` before returning:**

```java
@GetMapping("/{id}")
ResponseEntity<EntityModel<MemberDetailsResponse>> getMember(@PathVariable UUID id) {
Member member = managementService.getMember(new MemberId(id));
return ResponseEntity.ok(entityModelWithDomain(memberMapper.toDetailsResponse(member), member));
ResponseEntity<MemberDetailsResponse> getMember(@PathVariable UUID id, @ActingUser CurrentUserData currentUser) {
Member member = managementService.getMemberAndRecordView(new MemberId(id), currentUser.userId(), ...);

HalResponseContext.setDomain(member); // must run after everything that can throw
return ResponseEntity.ok(memberMapper.toDetailsResponse(member));
}
```

For a paginated collection, use `setDomainList` — same order as the DTO `Page` content, paired 1:1 by index:

```java
@GetMapping
ResponseEntity<Page<MemberSummaryResponse>> listMembers(@ParameterObject Pageable pageable, ...) {
Page<Member> memberPage = memberRepository.findAll(filter, pageable);

HalResponseContext.setDomainList(memberPage.getContent());
return ResponseEntity.ok(memberPage.map(memberMapper::toSummaryResponse));
}
```

The controller does NOT add links/affordances inline. It just wraps the DTO with the domain aggregate and returns. `EntityModelWithDomain<T, D>` is a subclass of `EntityModel<T>` that piggy-backs the domain object; the domain is `@JsonIgnore`-annotated so it never leaks into the response body.
**Always call `HalResponseContext.set*` last, after any code that can throw.** If the controller throws afterwards, `MvcExceptionHandler` returns a `ProblemDetail`; `HalResponseBodyAdvice` detects that and clears the context instead of wrapping the error body, but only if nothing between `set*` and the exception can leave stale context data for a *different* concern.

**Postprocessor — extend `ModelWithDomainPostprocessor<T, D>`:**
**What the advice does, automatically, with no controller involvement:**
- Single DTO → wraps it in `EntityModelWithDomain<T, D>` and runs it through every `RepresentationModelProcessor` bean — including `ModelWithDomainPostprocessor<Dto, Aggregate>` postprocessors.
- `Page<Dto>` → runs it through `PagedResourcesAssembler`, pairing each DTO with its domain object via `HalResponseContext`'s stashed list, then derives the **self link directly from the current request's path and query parameters** (no `klabisLinkTo` call needed for the self link — the controller method already ran and passed authorization for exactly this request).
- Non-HAL content types (e.g. `MemberOptionResponse` served as plain `application/json`) are left untouched — the advice checks `selectedContentType` and only wraps `HAL_JSON`/`HAL_FORMS_JSON` responses.
- A `ProblemDetail` error body is never wrapped, and the context is cleared so nothing leaks into a later request on the same thread pool.

**Postprocessor — extend `ModelWithDomainPostprocessor<T, D>`, which receives the DTO-shaped `EntityModel<T>` and the domain aggregate `D`:**

```java
@MvcComponent
class MemberDetailsPostprocessor extends ModelWithDomainPostprocessor<MemberDetailsResponse, Member> {

@Override
public void process(EntityModel<MemberDetailsResponse> dtoModel, Member member) {
klabisLinkTo(methodOn(MemberController.class).getMember(member.getId().uuid()))
klabisLinkTo(methodOn(MemberController.class).getMember(member.getId().uuid(), null))
.map(link -> {
var self = link.withSelfRel()
.andAffordances(klabisAfford(methodOn(MemberController.class).updateMember(member.getId().uuid(), null, null)));
Expand All @@ -282,19 +302,28 @@ class MemberDetailsPostprocessor extends ModelWithDomainPostprocessor<MemberDeta
}
```

**Why this pattern:**
- State-driven affordances read from the real aggregate (`member.isActive()`) — no reliance on whether the DTO field has already been filtered by Jackson field-level security.
- Controllers stay small; all hypermedia shaping is externalized. Multiple postprocessors can compose for the same endpoint (e.g. cross-module concerns: a training-groups postprocessor adding a `trainingGroup` link to member details).
- Cross-module postprocessors live in the consuming module — they declare their dependency on the DTO+domain pair explicitly via generics.
- `domainItem` is `@JsonIgnore` — safe from serialization.
**Collection-level affordances (not per item) go on the `PagedModel` itself**, in a plain `RepresentationModelProcessor<PagedModel<EntityModel<Dto>>>` — the self link already exists (built by the advice), this processor only adds affordances that point at *other* endpoints:

**Fallback — plain `EntityModel.of(dto)`:** acceptable only when there is no domain aggregate in scope (e.g., pure DTO projections, synthetic summaries, `RootModel` navigation). For standard aggregate-backed endpoints use the postprocessor pattern.

**Static import:**
```java
import static com.klabis.common.ui.HalFormsSupport.entityModelWithDomain;
@MvcComponent
class MemberListPostprocessor implements RepresentationModelProcessor<PagedModel<EntityModel<MemberSummaryResponse>>> {

@Override
public PagedModel<EntityModel<MemberSummaryResponse>> process(PagedModel<EntityModel<MemberSummaryResponse>> pagedModel) {
pagedModel.mapLink(IanaLinkRelations.SELF, selfLink -> (Link) selfLink
.andAffordances(klabisAfford(methodOn(MemberController.class).updateMember(null, null, null)))
.andAffordances(klabisAfford(methodOn(RegistrationController.class).registerMember(null, null))));
return pagedModel;
}
}
```

**Why this pattern:**
- Controllers return the exact type the OpenAPI-generated API interface requires — no `EntityModel`/`PagedModel` in the method signature, so the generated interface can be implemented directly once that migration lands for a module.
- State-driven affordances still read from the real aggregate (`member.isActive()`) — the postprocessor pipeline is unchanged, only how it gets invoked (advice vs. HATEOAS's own `HandlerMethodReturnValueHandler`, which only fires for return values that are already a `RepresentationModel`).
- The self link for a collection no longer needs a `klabisLinkTo(methodOn(...).listMembers(pageable, q, status, null))` call re-deriving the current request in the controller — it's built once, generically, by the advice for every paginated endpoint.
- Non-aggregate-backed responses (pure projections like `MemberOptionResponse`, served as plain JSON) are naturally skipped — no `HalResponseContext` entry means the advice passes the body through unchanged.

### HATEOAS Rules (NON-NEGOTIABLE)

Use `klabisLinkTo()` (returns `Optional<WebMvcLinkBuilder>`) and `klabisAfford()` — not standard Spring HATEOAS helpers.
Expand Down Expand Up @@ -326,7 +355,8 @@ Same HATEOAS rules apply — no affordances to POST endpoints.

| Situation | Use |
|---|---|
| Controller loads an aggregate and returns its detail/summary | `ModelWithDomainPostprocessor<Dto, Aggregate>` — controller returns `entityModelWithDomain(dto, aggregate)` |
| Controller loads an aggregate and returns its detail/summary | `ModelWithDomainPostprocessor<Dto, Aggregate>` — controller calls `HalResponseContext.setDomain(aggregate)` before returning the plain DTO |
| Collection-level affordances to other endpoints | Plain `RepresentationModelProcessor<PagedModel<EntityModel<Dto>>>` — the self link itself is built by `HalResponseBodyAdvice`; this processor only adds affordances |
| Root navigation (`RootModel`) | Plain `RepresentationModelProcessor<EntityModel<RootModel>>` — no domain involved |
| Cross-module link enrichment where consuming module knows only the DTO's marker interface and the publishing controller does not expose the aggregate | Plain `RepresentationModelProcessor<EntityModel<MarkerInterface>>` |

Expand Down Expand Up @@ -512,15 +542,14 @@ record MemberDetailResponse(
) {}
```

Controller returns a plain record — no proxy call needed:
Controller returns a plain record — no proxy call needed. Field security applies during Jackson serialization regardless of when in the response pipeline the DTO gets wrapped into `EntityModel` (see the HATEOAS section above):

```java
@GetMapping("/{id}")
EntityModel<MemberDetailResponse> getMember(@PathVariable UUID id) {
MemberDetailResponse response = memberMapper.toDetailResponse(member);
return EntityModel.of(response)
.add(klabisLinkTo(methodOn(MemberController.class).getMember(id)).withSelfRel()
.andAffordances(klabisAfford(methodOn(MemberController.class).updateMember(id, null, null))));
ResponseEntity<MemberDetailResponse> getMember(@PathVariable UUID id) {
Member member = managementService.getMember(new MemberId(id));
HalResponseContext.setDomain(member);
return ResponseEntity.ok(memberMapper.toDetailResponse(member));
}
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,40 @@ Maps HAL-FORMS property types to React components:
| boolean | HalFormsBoolean |
| datetime | HalFormsDateTime |

Extend with `expandHalFormsFieldFactory(customFactory)` to add custom field types.
**Signature:** `halFormsFieldsFactory(fieldType: string, conf: HalFormsInputProps, customFactory?: CustomFieldFactory): ReactElement | null`

Dispatch order for every call:
1. **`multi` always wins first** — if `conf.prop` is multi-valued (and has no `options`/`suggest`), the property routes to `HalFormsCollectionField` unconditionally, before `customFactory` ever runs. `HalFormsCollectionField` then recurses per row (`multiple: false`) back through the same custom-aware factory.
2. **`customFactory`** is consulted next — a custom type wins over any built-in default.
3. **Built-in `switch`** on `fieldType` (the table above) as the final fallback.

Because of step 1, a `CustomFieldFactory` only ever receives a **single-row** call for a multi-valued property — it never sees the array itself and does not need to special-case `isMultipleProperty` to avoid being called at the array level.

**Important:** a custom type must never try to render its own multi-value UI by checking `isMultipleProperty` inside the `CustomFieldFactory` — step 1 already claimed the array before the factory runs, so that check will only ever see single-row calls and is dead code for the array case. If a custom type needs different behavior for the multi-valued case (e.g. `MemberId`/`UUID`, which used to render one combined checkbox-group for a multi-select), re-express it as a plain single-field component and let the standard `multi` → `HalFormsCollectionField` mechanism iterate it instead: the base `multi` branch routes the array to `HalFormsCollectionField`, which recurses per row (`multiple: false`) back into the same custom factory, landing on the same single-field component once per item — with add/remove handled generically. This trades one combined widget for N per-row rows, but requires no special exemption in the framework.

**`fieldFactory` is not part of `HalFormsInputProps`.** It used to be threaded through `conf.fieldFactory` solely so `HalFormsCollectionField` could recurse into it; it is now an explicit component prop on `HalFormsCollectionField` (`<HalFormsCollectionField {...conf} fieldFactory={boundFactory(customFactory)} />`, an internal helper in `HalFormsFieldFactory.tsx`), set only in the `multi` branch above. Don't read or set `fieldFactory` off `HalFormsInputProps`/`conf` anywhere else — `HalFormsForm`'s `subElementProps` no longer carries it either.

**Extending with custom field types:** use `expandHalFormsFieldFactory(customFactory: CustomFieldFactory): HalFormFieldFactory` — it binds your custom logic into `halFormsFieldsFactory`'s 3rd parameter and returns a normal `HalFormFieldFactory` (the shape `HalFormButton`/`HalFormDisplay`/`HalFormsForm` consumers expect, so call-sites don't change).

```ts
type CustomFieldFactory = (fieldType: string, conf: HalFormsInputProps) => ReactElement | null;

const myCustomFactory: CustomFieldFactory = (fieldType, conf) => {
if (fieldType === 'MyCustomType') return <MyCustomField {...conf} />;
return null; // falls through to the next factory / built-ins
};

export const myFieldsFactory = expandHalFormsFieldFactory(myCustomFactory);
```

To compose several custom factories (e.g. a base factory plus feature-specific extras), chain them by falling through to the previous one instead of nesting `expandHalFormsFieldFactory` calls:

```ts
const combinedCustomFactory: CustomFieldFactory = (fieldType, conf) =>
myExtraCaseOrNull(fieldType, conf) ?? baseCustomFactory(fieldType, conf);

export const combinedFieldsFactory = expandHalFormsFieldFactory(combinedCustomFactory);
```

## HATEOAS Utility Functions

Expand Down
Loading
Loading