Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions migration/1779454406844-AddSupportIssueTemplate.js
Original file line number Diff line number Diff line change
@@ -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"`);
}
}
6 changes: 6 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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({
Expand Down
85 changes: 85 additions & 0 deletions src/shared/middlewares/api-trace.middleware.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>).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();
};
}
Original file line number Diff line number Diff line change
@@ -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> = {}): 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);
});
});
});
42 changes: 40 additions & 2 deletions src/subdomains/generic/kyc/dto/mapper/kyc-info.mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<KycStepName>,
): KycProcessStatus {
if (userData.isKycTerminated) return KycProcessStatus.FAILED;

const requiredSteps = kycSteps.filter((s) => requiredStepNames.has(s.name));

const actionable = new Set<ReviewStatus>([
ReviewStatus.NOT_STARTED,
ReviewStatus.IN_PROGRESS,
ReviewStatus.FAILED,
ReviewStatus.OUTDATED,
]);
const pending = new Set<ReviewStatus>([
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 [];
Expand Down
Loading
Loading