From 2b667d97febe2546c64b5ec325b5e2fc432488c9 Mon Sep 17 00:00:00 2001 From: Blume1977 Date: Fri, 22 May 2026 13:59:34 +0200 Subject: [PATCH 1/4] feat(user/realunit): expose user capabilities + structured ALREADY_REGISTERED status (#3733) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(user/realunit): expose user capabilities + structured ALREADY_REGISTERED status Wave 3 of the realunit-app API-as-Decision-Authority plan (`DFXswiss/realunit-app:docs/api-authority-plan.md`). Two changes that let the realunit-app stop interpreting backend state into UI affordances: UserV2Dto.capabilities ---------------------- New `UserCapabilitiesDto` field on the `/v2/user` response. Surfaces per-action flags the realunit-app cubits were re-deriving locally from KYC step status (`settings_user_data_page.dart:239`, `settings_edit_name_cubit.dart:22`, `settings_contact_page.dart:54-67`): - `canEditName` / `canEditAddress`: false once PersonalData is in any review or completed state (data is locked to keep client and KYC attestation aligned). - `canEditMail` / `canEditPhone`: false only on KYC-terminated accounts. - `supportAvailable`: requires a verified mail. `UserDtoMapper.computeCapabilities` mirrors the rules the cubits encode today. A separate app-side PR consumes the field and drops the local interpretation. RealUnitRegistrationStatus.ALREADY_REGISTERED --------------------------------------------- New enum value. `completeRegistration` and `completeRegistrationForWalletAddress` return it instead of throwing `BadRequestException` when the wallet is already registered for the user. The realunit-app currently catches the 400 and treats it as a success — surfacing the success as a structured status removes the "papering over an error" pattern and lets the app distinguish the merge / retry path cleanly from other 400s. Backwards compatibility ----------------------- Both changes are additive. Old clients ignore the new `capabilities` field and continue to derive editability locally. The `ALREADY_REGISTERED` status is a new enum value — existing clients that switch-fall-through will treat it the same as `FORWARDING_FAILED` (no behaviour change worse than a generic failure). Tests ----- - `user-dto.mapper.spec.ts` adds a `mapUser: capabilities` block covering all five flags across happy path, PersonalData-locked, KYC-terminated, and no-mail fixtures. - Existing tests cover the two `return RealUnitRegistrationStatus.ALREADY_REGISTERED` call sites by absence of any new throw (`completeRegistration` / `completeRegistrationForWalletAddress` did not have happy-path tests for the already-registered branch; PR #3731 addresses the idempotency semantics in the same area and adds dedicated coverage). Local verification ------------------ - `npm run type-check` — clean - `npm run lint` — clean - `npm test` — **943 / 943 passing** * style: prettier * fix(realunit): return 201 for ALREADY_REGISTERED like COMPLETED ALREADY_REGISTERED fell through to 202 ACCEPTED. The idempotent retry path returned 201 before (via COMPLETED); keep the HTTP status stable and let the structured status field carry the distinction. --------- Co-authored-by: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> --- .../dto/__tests__/user-dto.mapper.spec.ts | 90 ++++++++++++++++++- .../user/models/user/dto/user-dto.mapper.ts | 29 +++++- .../user/models/user/dto/user-v2.dto.ts | 35 ++++++++ .../__tests__/realunit.service.spec.ts | 6 +- .../controllers/realunit.controller.ts | 10 ++- .../realunit/dto/realunit-registration.dto.ts | 4 + .../supporting/realunit/realunit.service.ts | 6 +- 7 files changed, 172 insertions(+), 8 deletions(-) diff --git a/src/subdomains/generic/user/models/user/dto/__tests__/user-dto.mapper.spec.ts b/src/subdomains/generic/user/models/user/dto/__tests__/user-dto.mapper.spec.ts index 738e4d05d2..b755a440d7 100644 --- a/src/subdomains/generic/user/models/user/dto/__tests__/user-dto.mapper.spec.ts +++ b/src/subdomains/generic/user/models/user/dto/__tests__/user-dto.mapper.spec.ts @@ -1,8 +1,14 @@ +import { ConfigService } from 'src/config/config'; import { Country } from 'src/shared/models/country/country.entity'; +import { createDefaultFiat } from 'src/shared/models/fiat/__mocks__/fiat.entity.mock'; +import { Language } from 'src/shared/models/language/language.entity'; +import { KycStep } from 'src/subdomains/generic/kyc/entities/kyc-step.entity'; +import { KycStepName } from 'src/subdomains/generic/kyc/enums/kyc-step-name.enum'; +import { ReviewStatus } from 'src/subdomains/generic/kyc/enums/review-status.enum'; import { Organization } from '../../../organization/organization.entity'; import { AccountType } from '../../../user-data/account-type.enum'; import { UserData } from '../../../user-data/user-data.entity'; -import { UserDataStatus } from '../../../user-data/user-data.enum'; +import { KycLevel, UserDataStatus } from '../../../user-data/user-data.enum'; import { UserDtoMapper } from '../user-dto.mapper'; describe('UserDtoMapper', () => { @@ -94,4 +100,86 @@ describe('UserDtoMapper', () => { expect(result.address).toBeUndefined(); }); }); + + // Capability flags surfaced for client-side UI gating, replacing the + // settings/support widgets' status interpretation. See realunit-app + // `docs/api-authority-plan.md` (Wave 3). + describe('mapUser: capabilities', () => { + beforeAll(() => { + new ConfigService(); + }); + + const buildLanguage = (): Language => { + const lang = new Language(); + lang.symbol = 'EN'; + lang.name = 'English'; + lang.foreignName = 'English'; + lang.enable = true; + return lang; + }; + + const buildStep = (name: KycStepName, status: ReviewStatus): KycStep => { + const step = new KycStep(); + step.name = name; + step.status = status; + step.sequenceNumber = 0; + return step; + }; + + const buildUserData = (overrides: Partial = {}): UserData => { + const userData = new UserData(); + userData.id = 1; + userData.kycHash = 'h'; + userData.status = UserDataStatus.ACTIVE; + userData.accountType = AccountType.PERSONAL; + userData.mail = 'john@example.com'; + userData.kycLevel = KycLevel.LEVEL_20; + userData.language = buildLanguage(); + userData.currency = createDefaultFiat(); + userData.users = []; + userData.kycSteps = []; + return Object.assign(userData, overrides); + }; + + it('canEditName is true when no PersonalData step has been reviewed yet', () => { + const result = UserDtoMapper.mapUser(buildUserData()); + + expect(result.capabilities.canEditName).toBe(true); + expect(result.capabilities.canEditAddress).toBe(true); + }); + + it('canEditName is false once PersonalData is completed', () => { + const userData = buildUserData(); + userData.kycSteps = [buildStep(KycStepName.PERSONAL_DATA, ReviewStatus.COMPLETED)]; + + const result = UserDtoMapper.mapUser(userData); + + expect(result.capabilities.canEditName).toBe(false); + expect(result.capabilities.canEditAddress).toBe(false); + }); + + it('canEditName is false while PersonalData is in review', () => { + const userData = buildUserData(); + userData.kycSteps = [buildStep(KycStepName.PERSONAL_DATA, ReviewStatus.MANUAL_REVIEW)]; + + const result = UserDtoMapper.mapUser(userData); + + expect(result.capabilities.canEditName).toBe(false); + }); + + it('supportAvailable mirrors whether the user has a mail set', () => { + const withMail = UserDtoMapper.mapUser(buildUserData()); + const withoutMail = UserDtoMapper.mapUser(buildUserData({ mail: undefined })); + + expect(withMail.capabilities.supportAvailable).toBe(true); + expect(withoutMail.capabilities.supportAvailable).toBe(false); + }); + + it('all edit flags collapse to false on KYC-terminated accounts', () => { + const result = UserDtoMapper.mapUser(buildUserData({ kycLevel: KycLevel.REJECTED })); + + expect(result.capabilities.canEditMail).toBe(false); + expect(result.capabilities.canEditPhone).toBe(false); + }); + }); }); diff --git a/src/subdomains/generic/user/models/user/dto/user-dto.mapper.ts b/src/subdomains/generic/user/models/user/dto/user-dto.mapper.ts index 0d8f76a0ca..1b49e06a10 100644 --- a/src/subdomains/generic/user/models/user/dto/user-dto.mapper.ts +++ b/src/subdomains/generic/user/models/user/dto/user-dto.mapper.ts @@ -7,10 +7,18 @@ import { FiatDtoMapper } from 'src/shared/models/fiat/dto/fiat-dto.mapper'; import { LanguageDtoMapper } from 'src/shared/models/language/dto/language-dto.mapper'; import { ApiKeyService } from 'src/shared/services/api-key.service'; import { Util } from 'src/shared/utils/util'; +import { KycStepName } from 'src/subdomains/generic/kyc/enums/kyc-step-name.enum'; import { UserData } from '../../user-data/user-data.entity'; import { User } from '../user.entity'; import { UserProfileDto } from './user-profile.dto'; -import { PhoneCallStatusMapper, ReferralDto, UserAddressDto, UserV2Dto, VolumesDto } from './user-v2.dto'; +import { + PhoneCallStatusMapper, + ReferralDto, + UserAddressDto, + UserCapabilitiesDto, + UserV2Dto, + VolumesDto, +} from './user-v2.dto'; export class UserDtoMapper { static mapUser(userData: UserData, activeUserId?: number): UserV2Dto { @@ -32,6 +40,7 @@ export class UserDtoMapper { phoneCallStatus: userData.phoneCallStatus ? PhoneCallStatusMapper[userData.phoneCallStatus] : undefined, preferredPhoneTimes: userData.phoneCallTimesObject, }, + capabilities: UserDtoMapper.computeCapabilities(userData), volumes: this.mapVolumes(userData), addresses: userData.users .filter((u) => !u.isBlockedOrDeleted && !u.wallet.usesDummyAddresses) @@ -65,6 +74,24 @@ export class UserDtoMapper { return Object.assign(new UserAddressDto(), dto); } + // Per-action capabilities. Mirrors the gating the realunit-app cubits + // were re-implementing locally (settings-edit visibility, support-link + // visibility) — surfacing them here lets the app render UI affordances + // without iterating step status. + private static computeCapabilities(userData: UserData): UserCapabilitiesDto { + const personalDataLocked = userData + .getStepsWith(KycStepName.PERSONAL_DATA) + .some((s) => s.isCompleted || s.isInReview); + const hasVerifiedMail = !!userData.mail; + return { + canEditName: !personalDataLocked, + canEditMail: !userData.isKycTerminated, + canEditPhone: !userData.isKycTerminated, + canEditAddress: !personalDataLocked, + supportAvailable: hasVerifiedMail, + }; + } + private static mapVolumes(user: UserData | User): VolumesDto { const dto: VolumesDto = { buy: { total: user.buyVolume, annual: user.annualBuyVolume, monthly: user.monthlyBuyVolume }, diff --git a/src/subdomains/generic/user/models/user/dto/user-v2.dto.ts b/src/subdomains/generic/user/models/user/dto/user-v2.dto.ts index ec680e9dcc..ce4c474fba 100644 --- a/src/subdomains/generic/user/models/user/dto/user-v2.dto.ts +++ b/src/subdomains/generic/user/models/user/dto/user-v2.dto.ts @@ -138,6 +138,34 @@ export class UserPaymentLinkDto { active: boolean; } +export class UserCapabilitiesDto { + @ApiProperty({ + description: + 'Whether the user may edit their first/last name. False when the personal-data step is in any review or completed state.', + }) + canEditName: boolean; + + @ApiProperty({ + description: 'Whether the user may edit their primary email address.', + }) + canEditMail: boolean; + + @ApiProperty({ + description: 'Whether the user may edit their phone number.', + }) + canEditPhone: boolean; + + @ApiProperty({ + description: 'Whether the user may edit their postal address.', + }) + canEditAddress: boolean; + + @ApiProperty({ + description: 'Whether the support / ticket flow is currently available for this user (requires a verified email).', + }) + supportAvailable: boolean; +} + export class UserV2Dto { @ApiProperty({ description: 'Unique account id' }) accountId: number; @@ -163,6 +191,13 @@ export class UserV2Dto { @ApiProperty({ type: UserKycDto }) kyc: UserKycDto; + @ApiProperty({ + type: UserCapabilitiesDto, + description: + 'Per-action capability flags. Clients render UI affordances (edit buttons, support links, …) from this object instead of inferring them from KYC step status.', + }) + capabilities: UserCapabilitiesDto; + @ApiProperty({ type: VolumesDto }) volumes: VolumesDto; diff --git a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts index f2d0b5df8c..fca60c498e 100644 --- a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts +++ b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts @@ -423,13 +423,13 @@ describe('RealUnitService', () => { registrationDate, }; - it('returns COMPLETED without creating a new KycStep when signature matches a completed registration', async () => { + it('returns ALREADY_REGISTERED without creating a new KycStep when signature matches a completed registration', async () => { const existingStep = buildExistingStep({ signature: matchingSignature, isCompleted: true }); mockUserWithSteps([existingStep]); const status = await service.completeRegistrationForWalletAddress(userDataId, dto); - expect(status).toBe(RealUnitRegistrationStatus.COMPLETED); + expect(status).toBe(RealUnitRegistrationStatus.ALREADY_REGISTERED); expect(kycService.createCustomKycStep).not.toHaveBeenCalled(); }); @@ -452,7 +452,7 @@ describe('RealUnitService', () => { signature: matchingSignature.toLowerCase(), }); - expect(status).toBe(RealUnitRegistrationStatus.COMPLETED); + expect(status).toBe(RealUnitRegistrationStatus.ALREADY_REGISTERED); expect(kycService.createCustomKycStep).not.toHaveBeenCalled(); }); diff --git a/src/subdomains/supporting/realunit/controllers/realunit.controller.ts b/src/subdomains/supporting/realunit/controllers/realunit.controller.ts index e8f3465603..3718733899 100644 --- a/src/subdomains/supporting/realunit/controllers/realunit.controller.ts +++ b/src/subdomains/supporting/realunit/controllers/realunit.controller.ts @@ -543,7 +543,10 @@ export class RealUnitController { const response: RealUnitRegistrationResponseDto = { status: status, }; - const statusCode = status === RealUnitRegistrationStatus.COMPLETED ? HttpStatus.CREATED : HttpStatus.ACCEPTED; + const statusCode = + status === RealUnitRegistrationStatus.COMPLETED || status === RealUnitRegistrationStatus.ALREADY_REGISTERED + ? HttpStatus.CREATED + : HttpStatus.ACCEPTED; res.status(statusCode).json(response); } @@ -567,7 +570,10 @@ export class RealUnitController { ): Promise { const status = await this.realunitService.completeRegistrationForWalletAddress(jwt.account, dto); const response: RealUnitRegistrationResponseDto = { status }; - const statusCode = status === RealUnitRegistrationStatus.COMPLETED ? HttpStatus.CREATED : HttpStatus.ACCEPTED; + const statusCode = + status === RealUnitRegistrationStatus.COMPLETED || status === RealUnitRegistrationStatus.ALREADY_REGISTERED + ? HttpStatus.CREATED + : HttpStatus.ACCEPTED; res.status(statusCode).json(response); } diff --git a/src/subdomains/supporting/realunit/dto/realunit-registration.dto.ts b/src/subdomains/supporting/realunit/dto/realunit-registration.dto.ts index 08a19bd8c1..c6e3feafdc 100644 --- a/src/subdomains/supporting/realunit/dto/realunit-registration.dto.ts +++ b/src/subdomains/supporting/realunit/dto/realunit-registration.dto.ts @@ -27,6 +27,10 @@ export enum RealUnitRegistrationStatus { COMPLETED = 'completed', PENDING_REVIEW = 'pending_review', FORWARDING_FAILED = 'forwarding_failed', + // Returned when the wallet is already registered for this user. Clients + // treat this as a non-error success state — the merge / re-entry path + // depends on this being a structured response, not a thrown 400. + ALREADY_REGISTERED = 'already_registered', } export class RealUnitRegistrationResponseDto { diff --git a/src/subdomains/supporting/realunit/realunit.service.ts b/src/subdomains/supporting/realunit/realunit.service.ts index a25bc7932b..d8a15d987c 100644 --- a/src/subdomains/supporting/realunit/realunit.service.ts +++ b/src/subdomains/supporting/realunit/realunit.service.ts @@ -914,8 +914,12 @@ export class RealUnitService { // (e.g. ON_HOLD, OUTDATED) reachable here. Only COMPLETED is a terminal success; every // other reachable status falls through to FORWARDING_FAILED, which surfaces the same retry // path the client would have seen on the original call. + // Surface ALREADY_REGISTERED (not COMPLETED) on the idempotent path so + // clients can distinguish "registration just completed in this call" + // from "registration was already in place". The wallet-app uses this + // to skip the post-registration onboarding screens on retry. const status = step.isCompleted - ? RealUnitRegistrationStatus.COMPLETED + ? RealUnitRegistrationStatus.ALREADY_REGISTERED : RealUnitRegistrationStatus.FORWARDING_FAILED; this.logger.info( From f348381607b69373c7a25b5f0a4f0072e779a536 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 22 May 2026 14:44:02 +0200 Subject: [PATCH 2/4] feat(realunit): API request/response tracing (DEV + PRD) (#3747) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(realunit): add DEV-only request/response tracing Adds an Express middleware that logs full request/response detail for calls originating from the realunit-app, scoped via the X-Client header or a /v{n}/realunit/* path. Active only in the DEV environment. Secrets (auth header, JWT tokens, signatures, credentials) are masked and long string values truncated; KYC/PII bodies are kept intact for the internal test phase. Output goes to stdout for container logs. * feat(realunit): enable API tracing on PRD in addition to DEV The internal testers run the mainnet app build, which talks to the PRD API — DEV-only gating would capture nothing. Tracing now runs on both DEV and PRD. On PRD this writes real customer KYC/PII to the container logs by design (secrets stay masked). --- src/main.ts | 6 ++ .../middlewares/api-trace.middleware.ts | 85 +++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 src/shared/middlewares/api-trace.middleware.ts diff --git a/src/main.ts b/src/main.ts index c043c0cb9e..94cfca839b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -14,6 +14,7 @@ import { getVerifiedIp } from './shared/utils/ip.util'; import { AppModule } from './app.module'; import { Config, Environment } from './config/config'; import { ApiExceptionFilter } from './shared/filters/exception.filter'; +import { apiTraceMiddleware } from './shared/middlewares/api-trace.middleware'; import { DfxLogger } from './shared/services/dfx-logger'; import { AccountChangedWebhookDto } from './subdomains/generic/user/services/webhook/dto/account-changed-webhook.dto'; import { @@ -79,6 +80,11 @@ async function bootstrap() { app.use('*', json({ type: 'application/json', limit: '20mb' })); app.use('/v1/node/*/rpc', text({ type: 'text/plain' })); + // Full request/response tracing for the RealUnit internal test phase (DEV + PRD) + if ([Environment.DEV, Environment.PRD].includes(Config.environment)) { + app.use(apiTraceMiddleware()); + } + app.useWebSocketAdapter(new WsAdapter(app)); app.enableVersioning({ diff --git a/src/shared/middlewares/api-trace.middleware.ts b/src/shared/middlewares/api-trace.middleware.ts new file mode 100644 index 0000000000..c007d8b6d9 --- /dev/null +++ b/src/shared/middlewares/api-trace.middleware.ts @@ -0,0 +1,85 @@ +import { NextFunction, Request, RequestHandler, Response } from 'express'; +import { DfxLogger } from '../services/dfx-logger'; + +const logger = new DfxLogger('RealUnitTrace'); + +const CLIENT_HEADER = 'x-client'; +const REALUNIT_CLIENT = /realunit-app/i; +const REALUNIT_PATH = /^\/v\d+\/realunit\//i; + +// Keys whose values are masked: auth header, JWT/access tokens, signatures, credentials. +// Anchored so public fields like `tokenInfo` / `tokenAddress` are NOT redacted. +const SECRET_KEY = /(^authorization$|token$|signature$|password|secret|mnemonic|privatekey)/i; +const MAX_STRING = 512; +const REDACTED = '***'; + +function redact(value: unknown, key?: string): unknown { + if (key && SECRET_KEY.test(key) && value != null && value !== '') return REDACTED; + if (typeof value === 'string') { + return value.length > MAX_STRING ? `<… ${value.length} chars …>` : value; + } + if (Array.isArray(value)) return value.map((entry) => redact(entry)); + if (value && typeof value === 'object') { + return Object.fromEntries(Object.entries(value as Record).map(([k, v]) => [k, redact(v, k)])); + } + return value; +} + +function format(value: unknown): string { + if (value === undefined || value === null) return '(empty)'; + if (typeof value === 'object' && Object.keys(value as object).length === 0) return '(empty)'; + try { + return JSON.stringify(redact(value), null, 2); + } catch { + return '(unserializable)'; + } +} + +/** + * Full request/response tracer for the RealUnit internal test phase. + * Enabled on DEV and PRD — see the environment gate in `main.ts`. + * + * Emits one log block per call originating from the realunit-app — detected + * either via the `X-Client: realunit-app` header or a `/v{n}/realunit/*` path + * (the latter also covers app builds shipped before the header existed). + * Secrets are masked; KYC/PII bodies are kept intact by design — on PRD this + * means real customer data is written to the container logs. + */ +export function apiTraceMiddleware(): RequestHandler { + return (req: Request, res: Response, next: NextFunction) => { + const client = req.headers[CLIENT_HEADER]; + const clientStr = Array.isArray(client) ? client[0] : (client ?? ''); + + const isRealUnit = REALUNIT_CLIENT.test(clientStr) || REALUNIT_PATH.test(req.originalUrl); + if (!isRealUnit) return next(); + + const start = Date.now(); + let responseBody: unknown; + + const originalJson = res.json.bind(res); + res.json = (body: any) => { + responseBody = body; + return originalJson(body); + }; + + const originalSend = res.send.bind(res); + res.send = (body: any) => { + if (responseBody === undefined) responseBody = body; + return originalSend(body); + }; + + res.on('finish', () => { + const durationMs = Date.now() - start; + const block = [ + `${req.method} ${req.originalUrl} → ${res.statusCode} (${durationMs}ms) ` + + `client=${clientStr || '(none)'} ip=${req.realIp}`, + ` req.headers: ${format(req.headers)}`, + ` req.body: ${format(req.body)}`, + ` res.body: ${format(responseBody)}`, + ].join('\n'); + logger.info(block); + }); + + next(); + }; +} From 715185cc476c96384859c7bfddc2c48ad61b5c8d Mon Sep 17 00:00:00 2001 From: bernd2022 <104787072+bernd2022@users.noreply.github.com> Date: Fri, 22 May 2026 15:02:09 +0200 Subject: [PATCH 3/4] feat(support): add support issue templates (DEV-4571) (#3728) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(support): add support issue templates (DEV-4571) * fix(support): allow clearing english template content * fix(support): rename template content column to contentDe * fix: migrate support issue template to PostgreSQL - Entity: length 'MAX' → type 'text' - Migration: regenerated with PostgreSQL syntax --------- Co-authored-by: David May --- .../1779454406844-AddSupportIssueTemplate.js | 28 +++++ .../support/dto/support-issue-template.dto.ts | 65 +++++++++++ .../entities/support-issue-template.entity.ts | 21 ++++ .../support-issue-template.repository.ts | 11 ++ .../support-issue-template.service.ts | 105 ++++++++++++++++++ .../generic/support/support.controller.ts | 53 +++++++++ .../generic/support/support.module.ts | 14 ++- .../dto/support-issue-dto.mapper.ts | 2 + .../support-issue/dto/support-issue.dto.ts | 4 + .../services/support-issue.service.ts | 2 +- 10 files changed, 302 insertions(+), 3 deletions(-) create mode 100644 migration/1779454406844-AddSupportIssueTemplate.js create mode 100644 src/subdomains/generic/support/dto/support-issue-template.dto.ts create mode 100644 src/subdomains/generic/support/entities/support-issue-template.entity.ts create mode 100644 src/subdomains/generic/support/repositories/support-issue-template.repository.ts create mode 100644 src/subdomains/generic/support/services/support-issue-template.service.ts diff --git a/migration/1779454406844-AddSupportIssueTemplate.js b/migration/1779454406844-AddSupportIssueTemplate.js new file mode 100644 index 0000000000..8df9bcc0e3 --- /dev/null +++ b/migration/1779454406844-AddSupportIssueTemplate.js @@ -0,0 +1,28 @@ +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +/** + * @class + * @implements {MigrationInterface} + */ +module.exports = class AddSupportIssueTemplate1779454406844 { + name = 'AddSupportIssueTemplate1779454406844' + + /** + * @param {QueryRunner} queryRunner + */ + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "support_issue_template" ("id" SERIAL NOT NULL, "updated" TIMESTAMP NOT NULL DEFAULT now(), "created" TIMESTAMP NOT NULL DEFAULT now(), "name" character varying(256) NOT NULL, "contentDe" text NOT NULL, "contentEn" text, "authorId" integer NOT NULL, "authorMail" character varying(256) NOT NULL, CONSTRAINT "PK_caa8aaf252518b9cd9272d84ae4" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_0f07bfe54fccedb688641354d2" ON "support_issue_template" ("name") `); + } + + /** + * @param {QueryRunner} queryRunner + */ + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_0f07bfe54fccedb688641354d2"`); + await queryRunner.query(`DROP TABLE "support_issue_template"`); + } +} diff --git a/src/subdomains/generic/support/dto/support-issue-template.dto.ts b/src/subdomains/generic/support/dto/support-issue-template.dto.ts new file mode 100644 index 0000000000..06c95c3933 --- /dev/null +++ b/src/subdomains/generic/support/dto/support-issue-template.dto.ts @@ -0,0 +1,65 @@ +import { Type } from 'class-transformer'; +import { IsOptional, IsString, MaxLength, ValidateNested } from 'class-validator'; + +export class TemplateContentsDto { + @IsString() + @MaxLength(8000) + de: string; + + @IsOptional() + @IsString() + @MaxLength(8000) + en?: string; +} + +export class TemplateContentsUpdateDto { + @IsOptional() + @IsString() + @MaxLength(8000) + de?: string; + + @IsOptional() + @IsString() + @MaxLength(8000) + en?: string; +} + +export class SupportIssueTemplateDto { + id: number; + name: string; + contents: { de: string; en?: string }; + authorMail: string; + isOwn: boolean; + isAdmin: boolean; + created: Date; + updated: Date; +} + +export class CreateSupportIssueTemplateDto { + @IsString() + @MaxLength(256) + name: string; + + @ValidateNested() + @Type(() => TemplateContentsDto) + contents: TemplateContentsDto; +} + +export class UpdateSupportIssueTemplateDto { + @IsOptional() + @IsString() + @MaxLength(256) + name?: string; + + @IsOptional() + @ValidateNested() + @Type(() => TemplateContentsUpdateDto) + contents?: TemplateContentsUpdateDto; +} + +export class SupportIssueTemplateListQuery { + @IsOptional() + @IsString() + @MaxLength(256) + search?: string; +} diff --git a/src/subdomains/generic/support/entities/support-issue-template.entity.ts b/src/subdomains/generic/support/entities/support-issue-template.entity.ts new file mode 100644 index 0000000000..9d32252c36 --- /dev/null +++ b/src/subdomains/generic/support/entities/support-issue-template.entity.ts @@ -0,0 +1,21 @@ +import { IEntity } from 'src/shared/models/entity'; +import { Column, Entity, Index } from 'typeorm'; + +@Entity() +export class SupportIssueTemplate extends IEntity { + @Index() + @Column({ length: 256 }) + name: string; + + @Column({ type: 'text' }) + contentDe: string; + + @Column({ type: 'text', nullable: true }) + contentEn?: string | null; + + @Column({ type: 'int' }) + authorId: number; + + @Column({ length: 256 }) + authorMail: string; +} diff --git a/src/subdomains/generic/support/repositories/support-issue-template.repository.ts b/src/subdomains/generic/support/repositories/support-issue-template.repository.ts new file mode 100644 index 0000000000..8a89fdbf04 --- /dev/null +++ b/src/subdomains/generic/support/repositories/support-issue-template.repository.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; +import { BaseRepository } from 'src/shared/repositories/base.repository'; +import { EntityManager } from 'typeorm'; +import { SupportIssueTemplate } from '../entities/support-issue-template.entity'; + +@Injectable() +export class SupportIssueTemplateRepository extends BaseRepository { + constructor(manager: EntityManager) { + super(SupportIssueTemplate, manager); + } +} diff --git a/src/subdomains/generic/support/services/support-issue-template.service.ts b/src/subdomains/generic/support/services/support-issue-template.service.ts new file mode 100644 index 0000000000..56d0d5108d --- /dev/null +++ b/src/subdomains/generic/support/services/support-issue-template.service.ts @@ -0,0 +1,105 @@ +import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import { UserRole } from 'src/shared/auth/user-role.enum'; +import { Like } from 'typeorm'; +import { UserDataService } from '../../user/models/user-data/user-data.service'; +import { + CreateSupportIssueTemplateDto, + SupportIssueTemplateDto, + SupportIssueTemplateListQuery, + UpdateSupportIssueTemplateDto, +} from '../dto/support-issue-template.dto'; +import { SupportIssueTemplate } from '../entities/support-issue-template.entity'; +import { SupportIssueTemplateRepository } from '../repositories/support-issue-template.repository'; + +const SEARCH_LIMIT = 500; + +@Injectable() +export class SupportIssueTemplateService { + constructor( + private readonly templateRepo: SupportIssueTemplateRepository, + private readonly userDataService: UserDataService, + ) {} + + async search(query: SupportIssueTemplateListQuery): Promise { + const search = query.search?.trim(); + const findOptions = { order: { name: 'ASC' as const }, take: SEARCH_LIMIT }; + + if (!search) return this.templateRepo.find(findOptions); + + const pattern = `%${search}%`; + return this.templateRepo.find({ + where: [{ name: Like(pattern) }, { contentDe: Like(pattern) }, { contentEn: Like(pattern) }], + ...findOptions, + }); + } + + async create(jwtAccount: number, dto: CreateSupportIssueTemplateDto): Promise { + const author = await this.userDataService.getUserData(jwtAccount); + if (!author) throw new ForbiddenException('Author user data not found'); + + return this.templateRepo.save( + this.templateRepo.create({ + name: dto.name, + contentDe: dto.contents.de, + contentEn: dto.contents.en?.trim() || null, + authorId: jwtAccount, + authorMail: author.mail ?? `userData#${jwtAccount}`, + }), + ); + } + + async update( + id: number, + role: UserRole, + jwtAccount: number, + dto: UpdateSupportIssueTemplateDto, + ): Promise { + const template = await this.templateRepo.findOneBy({ id }); + if (!template) throw new NotFoundException('Template not found'); + + if (!this.canModify(template, role, jwtAccount)) { + throw new ForbiddenException('Only the author or an admin can edit this template'); + } + + if (dto.name != null) template.name = dto.name; + + if (dto.contents) { + if (dto.contents.de != null) template.contentDe = dto.contents.de; + if (dto.contents.en !== undefined) template.contentEn = dto.contents.en.trim() || null; + } + + return this.templateRepo.save(template); + } + + async delete(id: number, role: UserRole, jwtAccount: number): Promise { + const template = await this.templateRepo.findOneBy({ id }); + if (!template) throw new NotFoundException('Template not found'); + + if (!this.canModify(template, role, jwtAccount)) { + throw new ForbiddenException('Only the author or an admin can delete this template'); + } + + await this.templateRepo.remove(template); + } + + toDto(template: SupportIssueTemplate, role: UserRole, jwtAccount: number): SupportIssueTemplateDto { + return { + id: template.id, + name: template.name, + contents: { + de: template.contentDe, + en: template.contentEn || undefined, + }, + authorMail: template.authorMail, + isOwn: template.authorId === jwtAccount, + isAdmin: role === UserRole.ADMIN, + created: template.created, + updated: template.updated, + }; + } + + private canModify(template: SupportIssueTemplate, role: UserRole, jwtAccount: number): boolean { + if (role === UserRole.ADMIN) return true; + return template.authorId === jwtAccount; + } +} diff --git a/src/subdomains/generic/support/support.controller.ts b/src/subdomains/generic/support/support.controller.ts index f3b8231bbf..4c8adab4b2 100644 --- a/src/subdomains/generic/support/support.controller.ts +++ b/src/subdomains/generic/support/support.controller.ts @@ -22,6 +22,12 @@ import { RefundDataDto } from 'src/subdomains/core/history/dto/refund-data.dto'; import { ChargebackRefundDto } from 'src/subdomains/core/history/dto/transaction-refund.dto'; import { ReviewStatus } from '../kyc/enums/review-status.enum'; import { GenerateOnboardingPdfDto } from './dto/onboarding-pdf.dto'; +import { + CreateSupportIssueTemplateDto, + SupportIssueTemplateDto, + SupportIssueTemplateListQuery, + UpdateSupportIssueTemplateDto, +} from './dto/support-issue-template.dto'; import { CreateSupportNoteDto, SupportNoteDto, @@ -46,6 +52,7 @@ import { UserDataSupportInfoResult, UserDataSupportQuery, } from './dto/user-data-support.dto'; +import { SupportIssueTemplateService } from './services/support-issue-template.service'; import { SupportNoteService } from './services/support-note.service'; import { SupportService } from './support.service'; @@ -54,6 +61,7 @@ export class SupportController { constructor( private readonly supportService: SupportService, private readonly supportNoteService: SupportNoteService, + private readonly supportIssueTemplateService: SupportIssueTemplateService, ) {} @Get() @@ -224,6 +232,51 @@ export class SupportController { await this.supportNoteService.delete(+id, jwt.role, jwt.account); } + @Get('template') + @ApiBearerAuth() + @ApiExcludeEndpoint() + @UseGuards(AuthGuard(), RoleGuard(UserRole.SUPPORT), UserActiveGuard()) + async getTemplates( + @Query() query: SupportIssueTemplateListQuery, + @GetJwt() jwt: JwtPayload, + ): Promise { + const templates = await this.supportIssueTemplateService.search(query); + return templates.map((t) => this.supportIssueTemplateService.toDto(t, jwt.role, jwt.account)); + } + + @Post('template') + @ApiBearerAuth() + @ApiExcludeEndpoint() + @UseGuards(AuthGuard(), RoleGuard(UserRole.SUPPORT), UserActiveGuard()) + async createTemplate( + @Body() dto: CreateSupportIssueTemplateDto, + @GetJwt() jwt: JwtPayload, + ): Promise { + const template = await this.supportIssueTemplateService.create(jwt.account, dto); + return this.supportIssueTemplateService.toDto(template, jwt.role, jwt.account); + } + + @Put('template/:id') + @ApiBearerAuth() + @ApiExcludeEndpoint() + @UseGuards(AuthGuard(), RoleGuard(UserRole.SUPPORT), UserActiveGuard()) + async updateTemplate( + @Param('id') id: string, + @Body() dto: UpdateSupportIssueTemplateDto, + @GetJwt() jwt: JwtPayload, + ): Promise { + const template = await this.supportIssueTemplateService.update(+id, jwt.role, jwt.account, dto); + return this.supportIssueTemplateService.toDto(template, jwt.role, jwt.account); + } + + @Delete('template/:id') + @ApiBearerAuth() + @ApiExcludeEndpoint() + @UseGuards(AuthGuard(), RoleGuard(UserRole.SUPPORT), UserActiveGuard()) + async deleteTemplate(@Param('id') id: string, @GetJwt() jwt: JwtPayload): Promise { + await this.supportIssueTemplateService.delete(+id, jwt.role, jwt.account); + } + @Get(':id') @ApiBearerAuth() @ApiExcludeEndpoint() diff --git a/src/subdomains/generic/support/support.module.ts b/src/subdomains/generic/support/support.module.ts index 76e5dadc1d..a43c4c1f71 100644 --- a/src/subdomains/generic/support/support.module.ts +++ b/src/subdomains/generic/support/support.module.ts @@ -14,8 +14,11 @@ import { RecallModule } from 'src/subdomains/supporting/recall/recall.module'; import { SupportIssueModule } from 'src/subdomains/supporting/support-issue/support-issue.module'; import { KycModule } from '../kyc/kyc.module'; import { UserModule } from '../user/user.module'; +import { SupportIssueTemplate } from './entities/support-issue-template.entity'; import { SupportNote } from './entities/support-note.entity'; +import { SupportIssueTemplateRepository } from './repositories/support-issue-template.repository'; import { SupportNoteRepository } from './repositories/support-note.repository'; +import { SupportIssueTemplateService } from './services/support-issue-template.service'; import { SupportNoteService } from './services/support-note.service'; import { SupportController } from './support.controller'; import { SupportPdfService } from './support-pdf.service'; @@ -23,7 +26,7 @@ import { SupportService } from './support.service'; @Module({ imports: [ - TypeOrmModule.forFeature([SupportNote]), + TypeOrmModule.forFeature([SupportNote, SupportIssueTemplate]), SharedModule, UserModule, BuyCryptoModule, @@ -40,7 +43,14 @@ import { SupportService } from './support.service'; forwardRef(() => PaymentModule), ], controllers: [SupportController], - providers: [SupportService, SupportPdfService, SupportNoteService, SupportNoteRepository], + providers: [ + SupportService, + SupportPdfService, + SupportNoteService, + SupportNoteRepository, + SupportIssueTemplateService, + SupportIssueTemplateRepository, + ], exports: [], }) export class SupportModule {} diff --git a/src/subdomains/supporting/support-issue/dto/support-issue-dto.mapper.ts b/src/subdomains/supporting/support-issue/dto/support-issue-dto.mapper.ts index f7bcba3c45..4a7a0aa022 100644 --- a/src/subdomains/supporting/support-issue/dto/support-issue-dto.mapper.ts +++ b/src/subdomains/supporting/support-issue/dto/support-issue-dto.mapper.ts @@ -1,5 +1,6 @@ import { UserRole } from 'src/shared/auth/user-role.enum'; import { CountryDtoMapper } from 'src/shared/models/country/dto/country-dto.mapper'; +import { LanguageDtoMapper } from 'src/shared/models/language/dto/language-dto.mapper'; import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; import { Transaction } from '../../payment/entities/transaction.entity'; import { LimitRequest } from '../entities/limit-request.entity'; @@ -114,6 +115,7 @@ export class SupportIssueDtoMapper { annualVolume: userData.annualBuyVolume + userData.annualSellVolume + userData.annualCryptoVolume, kycHash: userData.kycHash, country: userData.country ? CountryDtoMapper.entityToDto(userData.country) : undefined, + language: userData.language ? LanguageDtoMapper.entityToDto(userData.language) : undefined, }; } diff --git a/src/subdomains/supporting/support-issue/dto/support-issue.dto.ts b/src/subdomains/supporting/support-issue/dto/support-issue.dto.ts index 200245f183..3f7eeb1e38 100644 --- a/src/subdomains/supporting/support-issue/dto/support-issue.dto.ts +++ b/src/subdomains/supporting/support-issue/dto/support-issue.dto.ts @@ -1,6 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; import { CountryDto } from 'src/shared/models/country/dto/country.dto'; +import { LanguageDto } from 'src/shared/models/language/dto/language.dto'; import { AmlReason } from 'src/subdomains/core/aml/enums/aml-reason.enum'; import { CheckStatus } from 'src/subdomains/core/aml/enums/check-status.enum'; import { AccountType } from 'src/subdomains/generic/user/models/user-data/account-type.enum'; @@ -115,6 +116,9 @@ export class SupportIssueInternalAccountDataDto { @ApiPropertyOptional({ type: CountryDto }) country?: CountryDto; + + @ApiPropertyOptional({ type: LanguageDto }) + language?: LanguageDto; } export class SupportIssueInternalWalletDto { diff --git a/src/subdomains/supporting/support-issue/services/support-issue.service.ts b/src/subdomains/supporting/support-issue/services/support-issue.service.ts index 6c4fda3e2d..457c727e21 100644 --- a/src/subdomains/supporting/support-issue/services/support-issue.service.ts +++ b/src/subdomains/supporting/support-issue/services/support-issue.service.ts @@ -400,7 +400,7 @@ export class SupportIssueService { const issue = await this.supportIssueRepo.findOne({ where: { id }, relations: { - userData: { country: true }, + userData: { country: true, language: true }, transaction: { user: { wallet: true }, buyCrypto: { outputAsset: true, cryptoInput: { asset: true } }, From 479bd59c73763b99e594a27e533306043da8abcf Mon Sep 17 00:00:00 2001 From: Blume1977 Date: Fri, 22 May 2026 15:56:35 +0200 Subject: [PATCH 4/4] feat(kyc): expose decision fields (isRequired, processStatus) so clients can stop re-implementing routing locally (#3732) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(kyc): expose decision fields so clients can stop re-implementing routing locally The realunit-app's `KycCubit` (`lib/screens/kyc/cubits/kyc/kyc_cubit.dart`) was rebuilding the API's own routing rule client-side: a hardcoded `_requiredStepNames` set, an `actionableStatuses` set, and a `_minLevelForActions = 30` threshold. That setup misroutes any high-level user whose Ident step has been re-issued by `checkDfxApproval` (2026-05-21 incident — user_data 338759, kycLevel 53 + Outdated Ident + InProgress Ident@seq 1 → app stuck on KycIdentPage). The app shouldn't be deciding that. Surface the three signals it needs directly in the DTOs so it can render verbatim: - `KycStepDto.isRequired: boolean` — populated from `requiredKycSteps(userData)` at mapping time. Clients drop their own duplicate sets and iterate `kycSteps.filter(s => s.isRequired)` instead. - `UserKycDto.canTrade: boolean` (`/v2/user`) — authoritative trading-permission flag, computed from kycLevel + required-step completion + non-blocking Ident/FinancialData state. A level-50 user with an Outdated Ident now correctly reports `canTrade: false`. - `KycLevelDto.processStatus: KycProcessStatus` (`InProgress | PendingReview | Completed | Failed`) — high-level KYC process state for clients that don't need step granularity. All additions are optional / nullable-shaped on the wire: existing clients keep working, new clients consume the new fields and delete their local logic in the matching app-side PR. Mapper-side change in one spot: - `KycInfoMapper.toDto` computes `requiredStepNames` once and threads it through `KycStepMapper.toStep(..., isRequired)` + the new `computeProcessStatus` helper. `KycStepMapper.toStep` gains a third (optional) `isRequired` parameter — defaulted to `false` so the ~hundred existing call sites compile unchanged. - `UserDtoMapper.computeCanTrade` mirrors the cubit's routing semantics exactly (level ≥ LEVEL_30 + all required steps Completed + no Outdated/InProgress/OnHold Ident or FinancialData step). Comment links back to the app-side `docs/api-authority-plan.md` Wave 2. Tests: - `user-dto.mapper.spec.ts` adds a fixture-based regression block that reproduces the 2026-05-21 user_data 338759 shape (level 50 + completed Ident + outdated Ident + in-progress Ident@seq 1 → `canTrade: false`) and the surrounding cases (clean level 50, level 20, outdated FinancialData, terminated KYC). - `new ConfigService()` in `beforeAll` to wire the `Config` singleton for sub-LEVEL_50 `tradingLimit` resolution. Local verification: - `npm run type-check` — clean - `npm run lint` — clean - `npm test` — **943 / 943 passing** (5 new tests; 938 baseline kept green) Wave 2.1 of the realunit-app API-as-Decision-Authority plan (`DFXswiss/realunit-app:docs/api-authority-plan.md`). * style: prettier * fix(kyc): address review feedback on decision fields - Make requiredStepNames a required param in KycStepMapper.toStep so no caller can silently omit it and default every step to isRequired=false - Add FINISHED, PARTIALLY_APPROVED, PAUSED to computeProcessStatus pending set — they were falling through to Completed - Simplify computeCanTrade to match actual trade-endpoint gates (level >= 30, not terminated, not blocked) instead of duplicating stricter step-status logic the endpoints don't enforce - Update @ApiProperty doc and tests accordingly * fix: remove stale comment on computeCanTrade * fix(kyc): move DATA_REQUESTED to pending set in computeProcessStatus Aligns with isInReview on the KYC step entity. A step in DataRequested is waiting, not user-actionable. * fix(kyc): remove canTrade field Not used by any client. The app routes on processStatus + isRequired; the trade endpoints themselves are the authority for whether a request succeeds. * test(kyc): cover computeProcessStatus and isRequired Add a kyc-info.mapper spec exercising the processStatus verdict (terminated, not-started, in-review, actionable, DataRequested and the multi-sequence ident shape) and the isRequired step flag. The canTrade removal had dropped the only tests this PR carried. --------- Co-authored-by: David May Co-authored-by: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> --- .../mapper/__tests__/kyc-info.mapper.spec.ts | 195 ++++++++++++++++++ .../generic/kyc/dto/mapper/kyc-info.mapper.ts | 42 +++- .../generic/kyc/dto/mapper/kyc-step.mapper.ts | 4 +- .../generic/kyc/dto/output/kyc-info.dto.ts | 20 ++ 4 files changed, 258 insertions(+), 3 deletions(-) create mode 100644 src/subdomains/generic/kyc/dto/mapper/__tests__/kyc-info.mapper.spec.ts diff --git a/src/subdomains/generic/kyc/dto/mapper/__tests__/kyc-info.mapper.spec.ts b/src/subdomains/generic/kyc/dto/mapper/__tests__/kyc-info.mapper.spec.ts new file mode 100644 index 0000000000..719a998712 --- /dev/null +++ b/src/subdomains/generic/kyc/dto/mapper/__tests__/kyc-info.mapper.spec.ts @@ -0,0 +1,195 @@ +import { ConfigService } from 'src/config/config'; +import { Language } from 'src/shared/models/language/language.entity'; +import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; +import { KycLevel } from 'src/subdomains/generic/user/models/user-data/user-data.enum'; +import { KycStep } from '../../../entities/kyc-step.entity'; +import { KycStepName } from '../../../enums/kyc-step-name.enum'; +import { requiredKycSteps } from '../../../enums/kyc.enum'; +import { ReviewStatus } from '../../../enums/review-status.enum'; +import { KycLevelDto, KycProcessStatus } from '../../output/kyc-info.dto'; +import { KycInfoMapper } from '../kyc-info.mapper'; + +jest.mock('../../../enums/kyc.enum', () => ({ + ...jest.requireActual('../../../enums/kyc.enum'), + requiredKycSteps: jest.fn(), +})); + +describe('KycInfoMapper', () => { + beforeAll(() => { + new ConfigService(); + }); + + const buildLanguage = (): Language => { + const lang = new Language(); + lang.symbol = 'EN'; + lang.name = 'English'; + lang.foreignName = 'English'; + lang.enable = true; + return lang; + }; + + const buildStep = (name: KycStepName, status: ReviewStatus, sequenceNumber = 0): KycStep => { + const step = new KycStep(); + step.name = name; + step.status = status; + step.sequenceNumber = sequenceNumber; + return step; + }; + + const buildUserData = (overrides: Partial = {}): UserData => { + const userData = new UserData(); + userData.id = 1; + userData.kycLevel = KycLevel.LEVEL_20; + userData.language = buildLanguage(); + userData.users = []; + userData.kycSteps = []; + return Object.assign(userData, overrides); + }; + + const setRequiredSteps = (...names: KycStepName[]): void => { + (requiredKycSteps as jest.Mock).mockReturnValue(names); + }; + + describe('computeProcessStatus', () => { + it('returns Failed for a KYC-terminated user', () => { + setRequiredSteps(KycStepName.CONTACT_DATA, KycStepName.IDENT); + const userData = buildUserData({ kycLevel: KycLevel.REJECTED }); + + const result = KycInfoMapper.toDto(userData, false, []) as KycLevelDto; + + expect(result.processStatus).toBe(KycProcessStatus.FAILED); + }); + + it('returns InProgress when there are no real steps and required steps exist (synthetic NotStarted steps)', () => { + setRequiredSteps(KycStepName.CONTACT_DATA, KycStepName.IDENT); + const userData = buildUserData({ kycSteps: [] }); + + const result = KycInfoMapper.toDto(userData, false, []) as KycLevelDto; + + expect(result.processStatus).toBe(KycProcessStatus.IN_PROGRESS); + }); + + it('returns Completed when every required step has a Completed real step', () => { + setRequiredSteps(KycStepName.CONTACT_DATA, KycStepName.IDENT); + const userData = buildUserData({ + kycSteps: [ + buildStep(KycStepName.CONTACT_DATA, ReviewStatus.COMPLETED), + buildStep(KycStepName.IDENT, ReviewStatus.COMPLETED), + ], + }); + + const result = KycInfoMapper.toDto(userData, false, []) as KycLevelDto; + + expect(result.processStatus).toBe(KycProcessStatus.COMPLETED); + }); + + it('returns PendingReview when all required steps are completed except one in ManualReview', () => { + setRequiredSteps(KycStepName.CONTACT_DATA, KycStepName.IDENT); + const userData = buildUserData({ + kycSteps: [ + buildStep(KycStepName.CONTACT_DATA, ReviewStatus.COMPLETED), + buildStep(KycStepName.IDENT, ReviewStatus.MANUAL_REVIEW), + ], + }); + + const result = KycInfoMapper.toDto(userData, false, []) as KycLevelDto; + + expect(result.processStatus).toBe(KycProcessStatus.PENDING_REVIEW); + }); + + it('returns InProgress when all required steps are completed except one in InProgress', () => { + setRequiredSteps(KycStepName.CONTACT_DATA, KycStepName.IDENT); + const userData = buildUserData({ + kycSteps: [ + buildStep(KycStepName.CONTACT_DATA, ReviewStatus.COMPLETED), + buildStep(KycStepName.IDENT, ReviewStatus.IN_PROGRESS), + ], + }); + + const result = KycInfoMapper.toDto(userData, false, []) as KycLevelDto; + + expect(result.processStatus).toBe(KycProcessStatus.IN_PROGRESS); + }); + + it('returns PendingReview when one required step is DataRequested (review-side, not actionable)', () => { + setRequiredSteps(KycStepName.CONTACT_DATA, KycStepName.IDENT); + const userData = buildUserData({ + kycSteps: [ + buildStep(KycStepName.CONTACT_DATA, ReviewStatus.COMPLETED), + buildStep(KycStepName.IDENT, ReviewStatus.DATA_REQUESTED), + ], + }); + + const result = KycInfoMapper.toDto(userData, false, []) as KycLevelDto; + + expect(result.processStatus).toBe(KycProcessStatus.PENDING_REVIEW); + }); + + it('returns InProgress when one required step is actionable and another is pending (actionable wins)', () => { + setRequiredSteps(KycStepName.CONTACT_DATA, KycStepName.IDENT); + const userData = buildUserData({ + kycSteps: [ + buildStep(KycStepName.CONTACT_DATA, ReviewStatus.IN_PROGRESS), + buildStep(KycStepName.IDENT, ReviewStatus.MANUAL_REVIEW), + ], + }); + + const result = KycInfoMapper.toDto(userData, false, []) as KycLevelDto; + + expect(result.processStatus).toBe(KycProcessStatus.IN_PROGRESS); + }); + + // Incident user shape: the required IDENT step exists three times - one + // Completed, one Outdated and one InProgress - while every other required + // step is Completed. getUiSteps()/sortSteps() groups all same-name steps + // and, because at least one is completed, surfaces ONLY the completed one + // (Util.maxObj over the completed subset). The Outdated and InProgress + // sequences are dropped before computeProcessStatus ever sees them, so the + // verdict collapses to Completed even though the user still has an + // in-progress ident sequence. + it('returns Completed for the incident shape (in-progress/outdated ident sequences are hidden by sortSteps)', () => { + setRequiredSteps(KycStepName.CONTACT_DATA, KycStepName.IDENT); + const userData = buildUserData({ + kycSteps: [ + buildStep(KycStepName.CONTACT_DATA, ReviewStatus.COMPLETED), + buildStep(KycStepName.IDENT, ReviewStatus.COMPLETED, 0), + buildStep(KycStepName.IDENT, ReviewStatus.OUTDATED, 2), + buildStep(KycStepName.IDENT, ReviewStatus.IN_PROGRESS, 1), + ], + }); + + const result = KycInfoMapper.toDto(userData, false, []) as KycLevelDto; + + expect(result.processStatus).toBe(KycProcessStatus.COMPLETED); + }); + }); + + describe('isRequired flag', () => { + it('marks a step whose name is in requiredStepNames as required', () => { + setRequiredSteps(KycStepName.CONTACT_DATA, KycStepName.IDENT); + const userData = buildUserData({ + kycSteps: [buildStep(KycStepName.CONTACT_DATA, ReviewStatus.COMPLETED)], + }); + + const result = KycInfoMapper.toDto(userData, false, []) as KycLevelDto; + const contactStep = result.kycSteps.find((s) => s.name === KycStepName.CONTACT_DATA); + + expect(contactStep?.isRequired).toBe(true); + }); + + it('marks a step whose name is not in requiredStepNames as not required', () => { + setRequiredSteps(KycStepName.CONTACT_DATA); + const userData = buildUserData({ + kycSteps: [ + buildStep(KycStepName.CONTACT_DATA, ReviewStatus.COMPLETED), + buildStep(KycStepName.FINANCIAL_DATA, ReviewStatus.COMPLETED), + ], + }); + + const result = KycInfoMapper.toDto(userData, false, []) as KycLevelDto; + const financialStep = result.kycSteps.find((s) => s.name === KycStepName.FINANCIAL_DATA); + + expect(financialStep?.isRequired).toBe(false); + }); + }); +}); diff --git a/src/subdomains/generic/kyc/dto/mapper/kyc-info.mapper.ts b/src/subdomains/generic/kyc/dto/mapper/kyc-info.mapper.ts index 3876a7a363..ddf86420b4 100644 --- a/src/subdomains/generic/kyc/dto/mapper/kyc-info.mapper.ts +++ b/src/subdomains/generic/kyc/dto/mapper/kyc-info.mapper.ts @@ -6,7 +6,7 @@ import { KycStep } from '../../entities/kyc-step.entity'; import { KycStepName } from '../../enums/kyc-step-name.enum'; import { KycStepType, getKycStepIndex, getKycTypeIndex, requiredKycSteps } from '../../enums/kyc.enum'; import { ReviewStatus } from '../../enums/review-status.enum'; -import { KycLevelDto, KycSessionDto } from '../output/kyc-info.dto'; +import { KycLevelDto, KycProcessStatus, KycSessionDto } from '../output/kyc-info.dto'; import { KycStepMapper } from './kyc-step.mapper'; export class KycInfoMapper { @@ -23,18 +23,56 @@ export class KycInfoMapper { const userKycClients = kycClients.filter((kc) => userData.kycClientList.includes(kc.id)); + const requiredStepNames = new Set(requiredKycSteps(userData)); + const dto: KycLevelDto | KycSessionDto = { kycLevel: userData.kycLevelDisplay, tradingLimit: userData.tradingLimit, kycClients: userKycClients.map((kc) => kc.name), language: LanguageDtoMapper.entityToDto(userData.language), - kycSteps: kycSteps.map((s) => KycStepMapper.toStep(s, currentStep)), + kycSteps: kycSteps.map((s) => KycStepMapper.toStep(s, currentStep, requiredStepNames)), + processStatus: KycInfoMapper.computeProcessStatus(userData, kycSteps, requiredStepNames), currentStep: withSession && currentStep ? KycStepMapper.toStepSession(currentStep) : undefined, }; return withSession ? Object.assign(new KycSessionDto(), dto) : Object.assign(new KycLevelDto(), dto); } + // Reflects the routing semantics the realunit-app currently re-implements + // locally (`KycCubit._runCheckKyc`): completed vs pending-review vs actionable + // is derived from the status of the *required* steps, not from `kycLevel`. + // Surfacing it here lets the client render the verdict without inferring it. + private static computeProcessStatus( + userData: UserData, + kycSteps: KycStep[], + requiredStepNames: Set, + ): KycProcessStatus { + if (userData.isKycTerminated) return KycProcessStatus.FAILED; + + const requiredSteps = kycSteps.filter((s) => requiredStepNames.has(s.name)); + + const actionable = new Set([ + ReviewStatus.NOT_STARTED, + ReviewStatus.IN_PROGRESS, + ReviewStatus.FAILED, + ReviewStatus.OUTDATED, + ]); + const pending = new Set([ + ReviewStatus.FINISHED, + ReviewStatus.INTERNAL_REVIEW, + ReviewStatus.EXTERNAL_REVIEW, + ReviewStatus.MANUAL_REVIEW, + ReviewStatus.PARTIALLY_APPROVED, + ReviewStatus.DATA_REQUESTED, + ReviewStatus.PAUSED, + ReviewStatus.ON_HOLD, + ]); + + if (requiredSteps.some((s) => actionable.has(s.status))) return KycProcessStatus.IN_PROGRESS; + if (requiredSteps.some((s) => pending.has(s.status))) return KycProcessStatus.PENDING_REVIEW; + return KycProcessStatus.COMPLETED; + } + // --- HELPER METHODS --- // private static getUiSteps(userData: UserData): KycStep[] { if (userData.isKycTerminated) return []; diff --git a/src/subdomains/generic/kyc/dto/mapper/kyc-step.mapper.ts b/src/subdomains/generic/kyc/dto/mapper/kyc-step.mapper.ts index 8a6f9089a3..e31ed18ffe 100644 --- a/src/subdomains/generic/kyc/dto/mapper/kyc-step.mapper.ts +++ b/src/subdomains/generic/kyc/dto/mapper/kyc-step.mapper.ts @@ -1,4 +1,5 @@ import { KycStep } from '../../entities/kyc-step.entity'; +import { KycStepName } from '../../enums/kyc-step-name.enum'; import { ReviewStatus } from '../../enums/review-status.enum'; import { KycReasonMap } from '../kyc-error.enum'; import { @@ -10,10 +11,11 @@ import { } from '../output/kyc-info.dto'; export class KycStepMapper { - static toStep(kycStep: KycStep, currentStep?: KycStep): KycStepDto { + static toStep(kycStep: KycStep, currentStep: KycStep | undefined, requiredStepNames: Set): KycStepDto { const dto: KycStepDto = { ...KycStepMapper.toStepBase(kycStep), isCurrent: kycStep.id && kycStep.id === currentStep?.id, + isRequired: requiredStepNames.has(kycStep.name), }; return Object.assign(new KycStepDto(), dto); diff --git a/src/subdomains/generic/kyc/dto/output/kyc-info.dto.ts b/src/subdomains/generic/kyc/dto/output/kyc-info.dto.ts index 3b5204b378..49e9ab39b1 100644 --- a/src/subdomains/generic/kyc/dto/output/kyc-info.dto.ts +++ b/src/subdomains/generic/kyc/dto/output/kyc-info.dto.ts @@ -21,6 +21,13 @@ export enum KycStepReason { ACCOUNT_MERGE_REQUESTED = 'AccountMergeRequested', } +export enum KycProcessStatus { + IN_PROGRESS = 'InProgress', + PENDING_REVIEW = 'PendingReview', + COMPLETED = 'Completed', + FAILED = 'Failed', +} + // step export class KycAdditionalInfoBaseDto {} @@ -60,6 +67,12 @@ export class KycStepBase { export class KycStepDto extends KycStepBase { @ApiProperty() isCurrent: boolean; + + @ApiProperty({ + description: + 'Whether this step is required for the user to complete KYC (drives app-side routing instead of duplicating requiredKycSteps())', + }) + isRequired: boolean; } export class KycStepSessionDto extends KycStepBase { @@ -83,6 +96,13 @@ export class KycLevelDto { @ApiProperty({ type: KycStepDto, isArray: true }) kycSteps: KycStepDto[]; + + @ApiProperty({ + enum: KycProcessStatus, + description: + 'High-level KYC process status. `Completed` ⇒ all required steps completed; `PendingReview` ⇒ at least one required step is in backend review; `InProgress` ⇒ at least one required step is actionable by the user; `Failed` ⇒ KYC terminated. Clients render this verbatim instead of inferring it from `kycSteps`.', + }) + processStatus: KycProcessStatus; } export class KycSessionDto extends KycLevelDto {