feat(user): add createSupportTicket capability with prerequisite hint#3772
Conversation
Narrow follow-up after #3767 was closed. That PR proposed a structured ActionCapability tree (170 LOC, 4 new DTOs, HttpMethod enum); @davidleomay correctly pointed out the endpoint paths are static and belong in Swagger docs, not on every /v2/user response. Pure-error pattern (his alternative suggestion) doesn't fit the actual UX requirement: the realunit-app needs to gate the support tile's tap *before* opening the ticket form, so the user is routed to the email capture step first — a post-submit 400 means the user fills the form for nothing. The middle ground here: - `UserCapabilitiesDto.createSupportTicket: { available, missingPrerequisite? }` — per-user runtime info only. The available flag and the missing- prerequisite discriminator are derived server-side from `userData.mail`. No endpoint paths in the response — those stay in Swagger. - `MissingPrerequisite` is a closed enum (`Email`, `Phone`). The app maps the value to the matching capture flow; the backend owns *which* prerequisite blocks the action, the app owns *how* the prerequisite is rendered. That's where API-as-Decision-Authority draws the line. - Mapper returns a discriminated union (`{ available: true } | { available: false; missingPrerequisite }`) so the compiler pins `!available implies missingPrerequisite defined`. The DTO class stays for Swagger schema generation (NestJS decorators don't model TS unions cleanly). - `@ApiBadRequestResponse` decorator added on `POST /v1/support/issue` per @davidleomay's review — Swagger docs now name the email-prerequisite remediation path. Description deliberately not narrowed to the email case so other 400 causes (DTO validation, etc.) remain covered. Backwards-compatible: `createSupportTicket?` is optional on the schema (`@ApiPropertyOptional`) so client SDKs pinned to older API versions ignore the new field. Other UserCapabilities flags untouched. ## Tests `user-dto.mapper.spec.ts` gains 4 fixtures: mail present, mail null, mail empty string, mail undefined. The empty-string and undefined cases pin the JS-truthy semantics so a future drift in `support-issue.service`'s gate is caught. - type-check: clean - lint: clean - prettier: clean - jest: 944 / 944 passing (940 baseline + 4 new tests)
| export enum MissingPrerequisite { | ||
| EMAIL = 'Email', | ||
| PHONE = 'Phone', | ||
| } |
There was a problem hiding this comment.
MissingPrerequisite.PHONE is dead code
There was a problem hiding this comment.
Right — YAGNI. Dropped in 3e26e1f. If a Phone-prerequisite gate gets added on the backend later, the enum extension is additive and backwards-compatible.
|
@davidleomay — explicit reasoning for why the PR landed where it did, including where we diverged from your suggestion: What we adopted from your review (closed PR #3767)
Where we divergedYour stronger implicit position is "the app should attempt 1. The UX requirement is pre-tap gating, not post-submit recoveryCyrill's explicit product requirement: when the support tile is tapped, if mail is missing, the user is routed into the email-capture step first, then to the ticket form. The tile itself stays unconditionally visible (per the recently-merged #588 — discoverable support for pre-signin onboarding). This requirement comes from the realunit-app being a pre-signin onboarding surface where many users land before registering an email — they need to discover support, and the path to enable it must be a single forward navigation, not "open form → fill it out → bounce off 400 → scroll to a settings page somewhere". 2. Reactive-error pattern means lost work and confusing UXIf the app attempts
Pre-tap check makes all of that disappear: at the moment of intent (tile tap), the app already knows whether to push the form or the prerequisite step. Zero retries, zero lost form data, zero error-body parsing for control flow. 3. The runtime cost is bounded and smallThe per-user payload added by this PR: 2 fields ( What's not shipped:
4. Alternatives we considered and rejected
The
5. How this aligns with the API authority plan
Open question for youIs the marginal per-user payload (the |
|
Makes sense, approved. |
YAGNI: the mapper only emits 'Email'. Add 'Phone' (or any other prerequisite) when a backend gate actually requires it — the enum extension would be additive and backwards-compatible. Addresses @davidleomay's review comment on PR #3772.
Synthesised from the #3733 → #3761 → #3767 → #3772 review sequence with @davidleomay. New section under "API Design" makes the eight rules binding for every future capability flag: 1. Heterogeneous capabilities — bool for hide-able, struct for discoverable 2. Static info belongs in Swagger, not in /user response 3. YAGNI for enum members and optional fields 4. Discriminated union for compiler-enforced invariants 5. Pre-tap signal for discoverable actions 6. Pair-PR with a documented trade-off 7. Backend owns business rules; client only maps types to UI 8. Reduction before extension — test the UX requirement first Cross-references the consumer-side mirror in DFXswiss/realunit-app's CONTRIBUTING.md and the api-authority-plan.md roll-out document. Pure documentation — no code changes.
…lessons (#593) Companion to [DFXswiss/api#3773](DFXswiss/api#3773). The David-review back-and-forth on the Wave-3 reset produced a concrete set of rules that should bind every future capability consumption in this repo — this PR writes them down so the next contributor doesn't have to rediscover them. ## Three docs updated ### 1. \`CONTRIBUTING.md\` — new sub-section "Consuming API capabilities — eight rules" Lives inside the existing "API as Decision Authority — CRITICAL" section. Covers: 1. Read the capability shape, don't reconstruct it 2. Tile/button visibility for discoverable actions is unconditional 3. Map prerequisite types to UI components, not to business rules 4. Legacy backend tolerance — capability optional, sane fallback 5. No reactive 400-handling for what a capability could pre-tell 6. Pair-PR discipline 7. Tests pin the contract, not the implementation 8. Push back on capability shape that's over-engineered Cross-references the API-side mirror in [\`DFXswiss/api:CONTRIBUTING.md\`](https://github.com/DFXswiss/api/blob/develop/CONTRIBUTING.md). ### 2. \`docs/api-authority-plan.md\` — Wave-3 lessons-learned block Documents the **six-PR** history of V9 (a single capability flag): | PR | Direction | Outcome | |---|---|---| | [api#3733](DFXswiss/api#3733) | API: \`+supportAvailable: bool\` | merged | | [app#497](#497) | App: consume \`supportAvailable\` bool | merged | | [app#588](#588) | App: unconditional Support tile | merged | | [api#3761](DFXswiss/api#3761) | API: \`-supportAvailable: bool\` | merged | | [api#3767](DFXswiss/api#3767) | API: ActionCapability tree (4 DTOs, 170 LOC) | **closed without merge** | | [api#3772](DFXswiss/api#3772) | API: \`createSupportTicket: { available, missingPrerequisite? }\` (91 LOC) | merged | Plus three "what we'd do differently" points and forward guidance for Waves 4 and 5. ### 3. \`docs/api-authority-audit.md\` — V9 closed Marked as closed by api#3772 with a link back to the lessons-learned section (because the linear "closed by W3" mapping in the audit table doesn't capture the actual non-linear history). ## Why now Cyrill asked for these rules to be prominently documented in both repos and in cross-repo working notes so the pattern doesn't drift on the next capability. The API side gets the mirror PR ([DFXswiss/api#3773](DFXswiss/api#3773)). ## Verification - Pure documentation — no code changes - Markdown renders cleanly (verified) - Cross-references resolve ## Companion PRs - API rules: [DFXswiss/api#3773](DFXswiss/api#3773) - App pair-PR consuming the first capability following this pattern (\`createSupportTicket\`): opening shortly on \`refactor/consume-support-capability\`.
) Companion app PR to [DFXswiss/api#3772](DFXswiss/api#3772) (merged 2026-05-26). **Closes V9** in [\`docs/api-authority-audit.md\`](docs/api-authority-audit.md). The Support tile now reads the new \`user.capabilities.createSupportTicket\` field for its tap decision. No more local \`mail != null\` reconstruction; the rule lives on the backend, the app maps a typed enum value to a UI step. ## Architecture Per the eight consumer rules in [\`CONTRIBUTING.md\`](CONTRIBUTING.md) → "Consuming API capabilities — eight rules" (documented in [#593](#593)): | State | Tap action | |---|---| | \`capability == null\` (legacy backend pre-#3772) | Direct push to Support — API is the authority | | \`capability.available == true\` | Direct push to Support | | \`capability.available == false, missingPrerequisite == email\` | Push email capture page; on \`pop(true)\` re-init the cubit and push Support if the refreshed capability is now available (or null — symmetric to branch 1) | | \`missingPrerequisite == unknown\` or \`null\` | Defensive direct push — let the API render the error | \`MissingPrerequisite\` is an **open enum** with \`email\` + \`unknown\`. Additive backend values degrade to \`unknown\` so a future prerequisite type never breaks \`/v2/user\` parsing for unrelated callers (KYC, settings, etc.). ## What changes ### \`lib/\` - \`packages/service/dfx/models/user/dto/user_dto.dart\` — \`UserCapabilitiesDto.createSupportTicket\` optional field + \`CreateSupportTicketCapabilityDto\` + open enum \`MissingPrerequisite\`. - \`screens/settings_contact/settings_contact_page.dart\` — BlocProvider-wrapped; Support tile \`onTap\` dispatches through \`_onSupportTap\` (4 branches above). Tile layout unchanged. - \`screens/settings_contact/cubit/...\` — new \`SettingsContactCubit\` + state (\`part of\` pattern, States extend Equatable). - \`screens/support/cubits/support_email_capture/...\` — new cubit + state for the standalone email capture flow. - \`screens/support/subpages/support_email_capture_page.dart\` — standalone page (no KYC coupling) calling \`RealUnitRegistrationService.registerEmail\`. \`mergeRequested\` status surfaces a dedicated message — the multi-step verification flow is deliberately not dragged into this minimal page. - \`setup/routing/routes/support_routes.dart\` + \`setup/routing/router_config.dart\` — new \`SupportRoutes.emailCapture\` under \`/support/email\`. - \`assets/languages/strings_{en,de}.arb\` — 4 new keys (alphabetically sorted, both languages). - \`lib/generated/i18n.dart\` — regenerated via \`dart run tool/generate_localization.dart\`. ### \`test/\` | File | Tests | |---|---| | \`packages/service/dfx/models/user/dto/user_dto_test.dart\` | +14 cases for createSupportTicket parsing, incl. \`unknown\` degradation and JSON-null handling | | \`screens/settings_contact/cubit/settings_contact_cubit_test.dart\` | 6 cases | | \`screens/settings_contact/cubit/settings_contact_state_test.dart\` | 8 cases | | \`screens/settings_contact/settings_contact_page_test.dart\` | 15 widget tests covering tile visibility + 11 routing branches incl. \`unknown\` and pop(null\|false) | | \`screens/support/cubits/support_email_capture/support_email_capture_cubit_test.dart\` | 5 cases (success, mergeRequested, ApiException, generic throw) | | \`screens/support/cubits/support_email_capture/support_email_capture_state_test.dart\` | 7 Equatable cases | | \`screens/support/subpages/support_email_capture_page_test.dart\` | 9 widget tests | | \`goldens/screens/support/support_email_capture_golden_test.dart\` | 2 goldens (default + submitting) | | \`goldens/screens/settings_contact/settings_contact_golden_test.dart\` | Re-baselined for BlocProvider wrap; visual surface unchanged | ## Local verification - \`dart format\` clean on all touched files - \`dart analyze lib/ test/\` — no issues found - \`flutter test\` on touched scope → **140/140 green** ## Review history Implemented + reviewed via internal subagent loop, three iterations: 1. First implementation off \`develop\` → had to rebase onto \`chore/post-580-followups\` (PR #588 base mismatch). 2. Reviewer found 2 SHOULD-FIX (Branch-1 asymmetry with \`?? false\` violating the no-fallback rule; \`MissingPrerequisite.fromString\` throw breaking unrelated \`/v2/user\` callers on additive backend changes). Both addressed: explicit null-check symmetry, open-enum \`unknown\` degradation. 3. Final reviewer pass found a \`dart format\` issue on the enum block — fixed; added an explicit \`unknown\`-routing widget test as a NICE-TO-HAVE. ## Targeting \`chore/post-580-followups\` Per request — this is a post-#580 follow-up that consumes a new API capability rather than introducing one in isolation. PR base set accordingly. --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Narrow follow-up after #3767 was closed. That PR proposed a structured ActionCapability tree (170 LOC, 4 new DTOs, HttpMethod enum); @davidleomay correctly pointed out the endpoint paths are static and belong in Swagger docs, not on every `/v2/user` response. The pure-error pattern (his alternative suggestion) doesn't fit the actual UX requirement: the realunit-app needs to gate the support tile's tap before opening the ticket form, so the user is routed to the email-capture step first — a post-submit 400 means the user fills the form for nothing.
The compromise
Size comparison vs #3767
Backwards compatibility
Tests
`user-dto.mapper.spec.ts` gains 4 fixtures:
Local verification
Review history
Reviewed internally via subagent loop. Two iterations — first pass flagged 3 SHOULD-FIX (discriminated union for type-safety, JS-path-leak in Swagger description, controller decorator scope), all addressed. Final verdict "Ready".
Pair-PR
Closes V9 in `DFXswiss/realunit-app:docs/api-authority-audit.md`. Companion app PR coming on `DFXswiss/realunit-app` to consume the new capability and replace the local mail-gating logic.
cc @davidleomay — interested in your take on the compromise. The Swagger decorator is in (your suggestion); the per-user discriminator is the smallest additional surface I could find that lets the app pre-check before the user starts filling out the form.