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/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(); + }; +} 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 { 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/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( 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 } },