npm install
npm run format # prettier --write
npm run lint # eslint
npm run type-check # tsc --noEmit
npm test # jest
npm run migration <Name> # generate migration from entity diffRun format, lint and type-check before pushing.
- Branch from
develop, never commit directly. - Feature branches:
feat/<scope>-<topic>, fixes:fix/<scope>-<topic>. - Commit messages: imperative mood, no trailing period on the subject.
- Squash-and-merge when merging to
develop— the squash keeps only the PR title. - Release PRs (
develop→main) are created automatically — never open them manually.
Every PR must include:
- Migration (if entity/column changes)
- Environment/Infrastructure updates (config, environment variables)
- Service updates (if DTOs/interfaces changed)
- Frontend synchronization (if API contracts changed)
Missing any of these = changes requested.
- Fix all linter errors and warnings (never disable lint rules without justification)
- Fix formatting (
npm run format) - Remove all
console.log/ debug statements - Resolve all TODO comments (if possible)
- Remove merge markers
- Check if changes need to be mirrored in related repos
- Clarity over cleverness — readable code beats short but obscure expressions
- Consistency — same patterns everywhere (naming, enum style, await handling)
- Minimal changes — use existing Util methods, don't reinvent the wheel
- Right layer — logic belongs in entities, not services; config in Config, not scattered
- Type safety — no
any, nostringwhere enums exist, always add return types - Performance awareness — filter in SQL not JS, use IDs not full entities in queries
- Clean API — use DTOs, never expose entities, document with ApiProperty
- No over-engineering — don't build it if the existing solution works
- Use existing packages — use
@dfx.swiss/*packages instead of duplicating logic
- camelCase everywhere: variables, config keys, URL routes, entity fields
- camelCase for abbreviations:
getUserByKycCodenotgetUserByKYCCode,dfiOrdersnotDFIOrders - American English spelling:
blankCenternotblankCentre - Plural for collections:
buys: Buy[]notbuy: Buy[],blockedIbansnotblockedIban - Boolean flags: positive naming:
safetyModeActivenotsafetyModuleInactive - Short, descriptive names — no redundant prefixes:
uidnottransactionRequestUid,balancesnotassetBalances,txIdnottransactionId - Variable names must precisely reflect the data:
priceChfnotamountChffor a price
- Methods are verbs:
logExists(),startNextStep(),cancelPayment() - Must describe what they do, not the implementation:
sendAutoResponsesnotsendAutoTemplates - Boolean getters use
is/has:isValid(),hasRole() - Invert negative names:
isDfxUsernotisExternalUser - No method-forwarding without logic: if a service method only delegates to a sub-service without adding value, remove it
- Name services by function:
KycSchedulerServicenot genericKycServiceif it only handles scheduling - Suffix reflects responsibility:
*SchedulerService,*NotificationService,*ValidationService
- Keys: UPPER_SNAKE_CASE
- Values: PascalCase strings:
BITCOIN = 'Bitcoin',CREATED = 'Created' - Never:
INVOICE = 'INVOICE'oractive = 'active' - Error enums use Mismatch pattern:
CardNameMismatchnotCARD_NAME_NOT_MATCHING - Reuse existing enum values: check if an existing error/status fits before creating a new one
- Entity files:
*.entity.ts(not just*.ts) - Enum files:
*.enum.ts - Generated files:
YYYYMMDD-Type-X-EntityId-HHMMSS(e.g.20250402-NameCheck-0-311124-163302.pdf) - No custom naming for TypeORM indexes
- PascalCase for class names:
DbQueryDtonotdbQueryDto - Name must reflect purpose:
GetBuyPaymentInfoDtonotCreateBuyPaymentInfoDtoif it's a getter - Field order aligned with entity field order for easy scanning
- Descriptive cache keys:
user-${userData.id}not just${userData.id}(avoids ID conflicts across entity types) - Named caches:
blockchainFeeCachenotcache,feeCachenotarrayCache - Include all variable parameters: use
JSON.stringify(request)as cache key when relations/filters vary
- Capital letter at start:
BankAccount not foundnotbankAccount not found - Consistency: same casing and style across all exception messages
// BAD: unnecessary intermediate variable
const result = await this.service.getData();
return result;
// GOOD: return directly
return this.service.getData();// BAD: unnecessary destructure + reconstruct
const { fee, refBonus } = await this.userService.getUserBuyFee(userId, volume);
return { fee, refBonus };
// GOOD
return this.userService.getUserBuyFee(userId, volume);// BAD
return this.dfxEnable ? true : false;
// GOOD
return this.dfxEnable;// Use ?? not ||
value ?? defaultValue // not: value || defaultValue
// Use ??= for default assignment
dto.blockchain ??= Blockchain.DEFICHAIN;
// not: if (!dto.blockchain) dto.blockchain = Blockchain.DEFICHAIN;
// Use ?. for optional chaining
if (!users?.length) // not: if (!users || users.length === 0)// BAD
const amount = availableAmount > 0 ? availableAmount : 0;
// GOOD
const amount = Math.max(availableAmount, 0);// Use .map() not for-loop + push
return banks.map(BankMapper.toDto);
// Use .includes() not .some() for equality
ipBlacklist.includes(ip); // not: ipBlacklist.some(b => b === ip)
// Use .some() not .find() for boolean checks
items.some((i) => i.active); // not: !!items.find(i => i.active)
// Use .find() not .filter()[0]
items.find((i) => i.id === id); // not: items.filter(i => i.id === id)[0]
// Use .endsWith() / .startsWith() for string checks
key.endsWith('0'); // not: key.charAt(key.length - 1) === '0'
// Sort by difference not comparison
transfers.sort((a, b) => b.timestamp - a.timestamp);
// not: transfers.sort((a, b) => a.timestamp > b.timestamp ? -1 : 1);const { k1, signature, key } = signupDto;
const [field, select] = entry.split('-');
for (const [blockchain, assets] of map.entries()) { ... }return this.paymentLinkService.get(id).then(PaymentLinkDtoMapper.toLinkDto);
return this.kycService.getCountries(code).then(CountryDtoMapper.entitiesToDto);Always ===, never ==.
// Always round divisions — JS is bad with floating-point
Util.round(value / divisor, 2);Util.round() Util.sumObjValue() Util.avg()
Util.daysBefore() Util.daysAfter() Util.daysDiff()
Util.groupBy() Util.randomString() Util.trim
Util.removeNullFields() Util.doInBatches() Util.doInBatchesAndJoin()Never reinvent what Util already provides.
// Use Config.formats for regex checks
Config.formats.bankUsage.test(key);
Config.formats.address.test(address);
// Use Config.xxx not GetConfig().xxx
Config.scrypt; // not: GetConfig().scrypt
// Use factors not percentages internally
0.012; // not: 1.2%// BAD: nested ternary soup
return a ? (b ? x : y) : (c ? z : w);
// GOOD: named intermediates
const percentFeeAmount = ...;
const feeAmount = ...;
return Math.max(percentFeeAmount, feeAmount);// Absolute paths, never relative
import { UserService } from 'src/subdomains/generic/user/services/user.service';
// not: import { UserService } from '../../user/services/user.service';
// Alphabetically sorted, multi-imports on separate lines
import {
PriceCurrency,
PriceValidity,
PricingService,
} from 'src/subdomains/supporting/pricing/services/pricing.service';Always use trailing commas in multi-line objects, arrays, and argument lists.
Use the // --- NAME --- // style for major sections:
// --- AUTH METHODS --- //
// --- HELPER METHODS --- //const url = `${baseUrl}/${encodeURIComponent(name)}`;
// not: url.split(' ')?.join('%20')- English only: no German comments in code
- Remove stale comments: delete comments that no longer reflect the code
- Remove commented-out code: dead code must be deleted
- TODOs must be resolved before merge (if possible)
- Currency hints on numeric config:
maxBlockchainFee = 50; // CHF
src/subdomains/
generic/ # shared domain models (user, kyc, support, ...)
supporting/ # infrastructure-level domains (bank-tx, mros, recall, ...)
core/ # business flows (buy-crypto, sell-crypto, ...)
Within a subdomain:
<domain>/
dto/
create-<domain>.dto.ts
update-<domain>.dto.ts
<domain>.entity.ts
<domain>.repository.ts
<domain>.service.ts
<domain>.controller.ts
<domain>.module.ts
Entities, DTOs, services stay in their domain. Move code to the correct domain, never duplicate.
// GOOD: computed properties and getters on the entity
class UserData extends IEntity {
get tradingLimit(): TradingLimit { ... }
get isDfxUser(): boolean { ... }
get hasActiveUser(): boolean { ... }
}
// BAD: business logic in service
class UserService {
getTradingLimit(userData: UserData): TradingLimit { ... }
}Entities carry computed properties, state transitions, and validation. State transitions use the UpdateResult pattern:
// The dominant entity pattern — 100+ usages across the codebase
setPriceInvalidStatus(): UpdateResult<BuyCrypto> {
const update: Partial<BuyCrypto> = {
status: BuyCryptoStatus.PRICE_INVALID,
...this.resetTransaction(),
};
Object.assign(this, update);
return [this.id, update];
}This returns [id, changedFields] so the caller can do a targeted repo.update(id, update) instead of a full repo.save(entity).
- Controllers: thin, delegate to services, never contain business logic
- Services: orchestrate business logic, call repositories
- Repositories: data access only, extend
BaseRepository<T>(orCachedRepository<T>for frequently accessed reference data like assets, fiats, countries)
- Services/repos should be provided in exactly one module
- Never provide the same service in multiple modules — import from the owning module
- Do not export repositories directly — export services
@Injectable()is not needed on static helper classes
// BAD: extending and duplicating caching
class MyClient extends EvmClient { ... }
// GOOD: composition
class MyService {
constructor(private readonly evmClient: EvmClient) {}
}- Extract helper methods for logic used across BuyCrypto/BuyFiat/Sell
- Use maps instead of switch/if-else chains (generates build errors for unmapped values):
const PaymentMethodMap: { [method in PaymentMethod]: PaymentType } = { ... };// BAD: hardcoded
const countries = ['DE', 'AT', 'CH'];
// GOOD: configurable
const countries = await this.settingService.get('allowedCountries');
// or in wallet table, or Config objectRemove @Inject(forwardRef(() => ...)) when circular dependencies can be resolved. Prefer constructor injection.
// BAD: passing walletName through 4+ method layers
translate(key, walletName) -> translateParams(key, walletName) -> getMailAffix(walletName) -> ...
// GOOD: resolve context once at the top
const context = this.buildTranslationContext(wallet);
translate(key, context);When adding a .filter() that affects existing data (not just new functionality), evaluate the impact on all existing consumers.
// BAD
async getUser(id: number) { ... }
// GOOD
async getUser(id: number): Promise<User | undefined> { ... }constructor(
private readonly transactionService: TransactionService,
private readonly repo: RecallRepository,
) {}// When Create and Update share fields, Create DTO can extend Update DTO
export class UpdateRiskAssessmentDto {
@IsOptional()
@IsString()
reason?: string;
}
export class CreateRiskAssessmentDto extends UpdateRiskAssessmentDto {
@IsNotEmpty()
@IsEnum(RiskType)
type: RiskType;
}
// Standalone DTOs are fine when Create and Update have different shapesKey rules:
@ApiPropertyOptional()for optional fields (not@ApiProperty())@ApiProperty({ enum: EnumType })for enum fields@ApiProperty({ type: ChildDto, isArray: true })for arrays@Type(() => ChildDto)+@ValidateNested({ each: true })for nested DTOs@Transform(Util.trim)on string inputs@IsEmail()for email fields@IsNotEmpty()required when using@ValidateIf()- Interface for internal DTOs, class for external DTOs (validator decorators are useless on internal DTOs)
- Create DTOs (
@IsOptional()): acceptundefinedornull - Update DTOs (
@IsOptionalButNotNull()fromshared/validators): acceptundefined, reject explicitnull
create: destructure relation ids and JSON-backed fields out of the DTO, create the entity from the rest, then attach relations and call the typed setter for JSON fieldsupdate: load the entity,Object.assign(entity, rest)for plain fields, use the typed setter for JSON fields when the DTO value is notundefined
@ApiOkResponse({ type: ResponseDto })on endpoints@ApiOperation({ deprecated: true })on deprecated endpoints@ApiExcludeEndpoint()on admin endpoints@ApiTags('Payment Link')with space in multi-word tags
@Controller('support/issue')
@ApiTags('Support Issue')
export class SupportIssueController {
@Put(':id') // not @Post for updates
@Delete(':id/payment') // nested resources
@Put(':id/reset') // not @Delete('reset/:id')
}- Status 200 for GET (not 201)
- Plain string responses are annoying — return JSON objects
Use @DfxCron (custom wrapper with built-in locking, process control, and error handling). It replaces @Cron + @Lock + DisabledProcess — never combine these manually with @DfxCron.
// GOOD: @DfxCron handles everything
@DfxCron(CronExpression.EVERY_MINUTE, { process: Process.PAYMENT, timeout: 1800 })
async processPayments(): Promise<void> {
// no @Lock, no DisabledProcess check needed
}
// ONLY with bare @Cron: manual @Lock + DisabledProcess required
@Cron(CronExpression.EVERY_MINUTE)
@Lock(1800)
async processPayments(): Promise<void> {
if (DisabledProcess(Process.PAYMENT)) return;
// ...
}Prefer longer intervals (15min) over aggressive polling (1min). Only use short intervals when truly needed.
// Always await async operations
await this.repo.save(entity);
// Use void for fire-and-forget (must handle errors)
void this.notificationService.send(msg).catch((e) => this.logger.error('...', e));
// Don't await before return (unless inside try-catch where you need the error caught)
return this.service.getData(); // not: return await this.service.getData();// BAD
async process(data: any): Promise<any> { ... }
// GOOD
async process(data: OrderData): Promise<Transaction> { ... }protectedfor abstract method implementations (not public)privategetter for computed values on services- Only make methods public when other modules need them
@Entity()
@Index((r: Recall) => [r.bankTx, r.checkoutTx, r.sequence], { unique: true })
export class Recall extends IEntity {
@ManyToOne(() => BankTx)
bankTx: BankTx;
@ManyToOne(() => CheckoutTx)
checkoutTx: CheckoutTx;
@Column({ type: 'int' })
sequence: number;
@ManyToOne(() => User, { nullable: true })
user?: User;
@Column({ type: 'text' })
comment: string;
@Column({ type: 'float' })
fee: number;
@Column({ length: 256, nullable: true })
reason?: RecallReason;
}Rules:
- Relations via lambda:
@ManyToOne(() => BankTx)— never strings nullable: falseexplicit where neededtype: 'timestamp'for date columnstype: 'text'for unlimited-length string columnseager: falseis the default — don't annotate it- Use
eager: truesparingly — explicit relation loading is preferred - Column length always specified
unique: trueon uniqueId columns- No custom index names
- New columns that existing rows can't populate →
nullable: true - New columns with a domain default →
default: 'value'
Use the getter/setter pattern for JSON data in columns:
@Column({ type: 'text', nullable: true })
indicators?: string; // JSON string
get indicatorCodes(): string[] {
return this.indicators ? JSON.parse(this.indicators) : [];
}
set indicatorCodes(codes: string[]) {
this.indicators = JSON.stringify(codes);
}Never expose the raw JSON string to business logic — always go through the typed getter/setter.
// Filter by ID, not full entity
{ where: { asset: { id: asset.id } } } // not: { where: { asset } }
// Use IsNull() in TypeORM where clauses
{ where: { deletedAt: IsNull() } } // not: null comparison
// Use In() for multiple values
{ where: { type: In([Type.A, Type.B]) } }
// Use non-string relations
relations: { user: true, buy: { route: true } } // not: ['user', 'buy.route']
// Use select object syntax
select: { id: true, name: true }
// innerJoin over leftJoin when possible (more performant)
// Use exists() for existence checks
await this.repo.exists({ where: { ... } }); // not: findBy + length
// Use countBy() when you need a count
await this.repo.countBy({ ... }); // not: findBy + length
// Use findOne() not find() + [0]
await this.repo.findOne({ where: { ... } });
// Use update() for partial field changes
await this.repo.update(id, dto); // not: findOne + Object.assign + save (when only changing fields)
// Prefer repository methods over query builder when possible
this.repo.find({ where: { ... }, relations: { ... } });
// not: this.repo.createQueryBuilder().andWhere().orderBy().getMany() // unless truly complexconst entity = this.repo.create({
...dto,
transaction: { id: transactionId },
});// BAD: separate queries for related data
const user = await this.userRepo.findOne({ where: { id } });
const wallet = await this.walletRepo.findOne({ where: { user: { id: user.id } } });
// GOOD: single query with relations
const user = await this.userRepo.findOne({ where: { id }, relations: { wallet: true } });Don't eagerly load relations unless truly always needed. Loading user with all relations for every query is wasteful. Use explicit relations: { ... } in each query to load only what's needed.
- Always add migration for entity/column changes — use
npm run migration <PascalName>to generate - JavaScript files with timestamp-based naming:
1756463340213-AddFeatureName.js - Never edit a migration after merge to DEV — add a follow-up migration instead
- Data-only migrations (UPDATE/INSERT/DELETE without schema changes) may be hand-written freely
Hand-written schema migrations must match TypeORM's deterministic constraint naming. The algorithm (from DefaultNamingStrategy.js):
<prefix>_ + sha1(tableName + '_' + columnNames.sort().join('_')).substring(0, N)
| Prefix | N | Constraint type |
|---|---|---|
PK_ |
27 | Primary key |
FK_ |
27 | Foreign key |
UQ_ |
27 | Unique |
DF_ |
27 | Default |
REL_ |
26 | Relation |
IDX_ |
26 | Index |
CHK_ |
26 | Check |
| Situation | Exception |
|---|---|
| Client sent bad data | BadRequestException |
| Resource not found | NotFoundException |
| Duplicate resource | ConflictException |
| Authorization failure | ForbiddenException |
| External service down | ServiceUnavailableException |
| Internal processing error | throw new Error(...) (plain Error, never InternalServerErrorException) |
| Order can't process now | OrderNotProcessableException (custom) |
| Order permanently failed | OrderFailedException (custom) |
// GOOD: flat structure
if (!withdrawal) return false;
if (withdrawal.status === 'failed') throw new OrderFailedException(...);
if (!withdrawal.txid) return false;
// BAD: nested if-else
if (!withdrawal?.txid) {
return false;
} else if (withdrawal.status === 'failed') {
throw new OrderFailedException(...);
}Single-line guard clauses when simple:
if (user.hasRole(UserRole.COMPLIANCE)) throw new BadRequestException('...');
if (this.networkValidated) return;Use DfxLogger (not the standard NestJS Logger):
private readonly logger = new DfxLogger(MyService);
// Format: description + entity ID + colon + error
this.logger.error(`Failed to check order status for fiat output ${entity.id}:`, e);
this.logger.info(`Withdrawal awaiting approval. UTXO: ${data.withdrawalUtxo}`);
this.logger.verbose(`Sent ${amount} BTC to signer: ${txId}`);
this.logger.warn(`Retrying fetchAll for ${streamName}: ${error.message}`);infofor business eventsverbosefor detailswarnfor retrieserrorfor failures- Log severity based on error type: missing data = INFO, real error = ERROR
// Always handle errors on non-awaited promises
this.service.doAsync().catch((e) => this.logger.error('Failed to process:', e));
// Use .catch(() => null) for graceful degradation
const result = await this.service.tryGet().catch(() => null);
// Use .catch(() => false) for boolean validation fallback
const isValid = await this.validateIban(iban).catch(() => false);// BAD: @DfxCron already handles errors
@DfxCron(CronExpression.EVERY_HOUR)
async process(): Promise<void> {
try { ... } catch (e) { this.logger.error(e); } // redundant
}
// GOOD
@DfxCron(CronExpression.EVERY_HOUR)
async process(): Promise<void> {
// just do the work
}const errors: string[] = [];
if (!valid1) errors.push('...');
if (!valid2) errors.push('...');
if (errors.length > 0) throw new Error(`Validation failed:\n${errors.join('\n')}`);- Always check for null before property access:
entity.recommendedmight be null find()returns undefined — handle it- Check
!= null(not!== 0) when 0 is a valid value - Watch for
[undefined]when mapping nullable arrays
// BAD: loading 5000 entities then filtering
const all = await this.repo.find();
return all.filter((e) => e.status === Status.ACTIVE);
// GOOD: SQL filter
return this.repo.find({ where: { status: Status.ACTIVE } });// BAD: multiple queries
const users = await this.userRepo.find();
for (const user of users) {
user.wallet = await this.walletRepo.findOne({ user });
}
// GOOD: single query with join
return this.userRepo.find({ relations: { wallet: true } });// BAD: O(n²) array search
for (const item of items) {
const match = list.find((l) => l.id === item.id);
}
// GOOD: create a map
const map = new Map(list.map((l) => [l.id, l]));
const match = map.get(item.id);// BAD
const entities = await this.repo.find({ where: { ... } });
for (const e of entities) { e.status = Status.DONE; await this.repo.save(e); }
// GOOD: single SQL update
await this.repo.update({ oldStatus }, { status: Status.DONE });query.where('log.message LIKE :message', { message: `%${id}%` });
// not: query.where(`log.message LIKE '%${id}%'`); // SQL injection risk- Don't fetch the same price/data twice in one method
- Use
AsyncCache,Map-based in-memory cache for hot data - Initial fetch + subscription for real-time data (not repeated polling)
// BAD: sequential when independent
const price = await this.pricingService.getPrice(asset);
const balance = await this.balanceService.getBalance(asset);
// GOOD: parallel
const [price, balance] = await Promise.all([
this.pricingService.getPrice(asset),
this.balanceService.getBalance(asset),
]);Map entities to DTOs using static mapper classes:
// Mapper class (e.g. dto/payment-link-dto.mapper.ts)
export class PaymentLinkDtoMapper {
static toLinkDto(paymentLink: PaymentLink): PaymentLinkDto { ... }
static toLinkDtoList(paymentLinks: PaymentLink[]): PaymentLinkDto[] {
return paymentLinks.map(PaymentLinkDtoMapper.toLinkDto);
}
}
// Controller usage
@Get(':id')
async get(@Param('id') id: string): Promise<PaymentLinkDto> {
return this.paymentLinkService.get(+id).then(PaymentLinkDtoMapper.toLinkDto);
}Never expose apiUrl, apiKey, address, or other sensitive internal fields in API responses.
Create separate output enums for API responses, decoupled from internal service enums, only if required.
async get(@Param('id') id: string): Promise<Entity> {
return this.service.get(+id); // not parseInt(id)
}Keep old endpoints for backward compatibility but annotate:
@ApiOperation({ deprecated: true })The RealUnit purchase and sale flows historically lived under /v1/realunit/brokerbot/*. That naming is misleading: most of those endpoints never touch the on-chain Brokerbot smart contract. Treat them as two distinct subsystems:
| Path | What it does | On-chain? |
|---|---|---|
GET /v1/realunit/quote/price |
Spot price per share | No — Aktionariat REST (/directinvestment/getPrice, 30 s cache) |
GET /v1/realunit/quote/buyPrice?shares=N |
N × price (buy direction) |
No |
GET /v1/realunit/quote/buyShares?amount=N |
floor(N / price) (buy direction) |
No |
GET /v1/realunit/quote/sellPrice?shares=N |
Estimated payout after user-specific fees | No — REST price + local fee math |
GET /v1/realunit/quote/sellShares?amount=N |
Reverse of the above | No |
GET /v1/realunit/quote/info |
Spot price + Brokerbot contract addresses (for clients that need them) | No |
PUT /v1/realunit/buy + /buy/:id/confirm |
Fiat IBAN flow — Aktionariat allocates shares off-chain via directinvestment/payAndAllocate |
No |
PUT /v1/realunit/sell |
Anchors the quote against the live on-chain sell price before returning payment-info | Yes — RealUnitBlockchainService.getBrokerbotSellPrice (viem readContract) |
PUT /v1/realunit/sell/:id/unsigned-transactions |
Reads the on-chain sell price and builds the EIP-7702 batch the user has to sign | Yes — RealUnitBlockchainService.getBrokerbotSellPrice |
PUT /v1/realunit/sell/:id/confirm |
Verifies the user-signed batch against the live on-chain sell price | Yes — RealUnitBlockchainService.getBrokerbotSellPrice |
PUT /v1/realunit/sell/:id/broadcast |
Submits the user-signed EIP-1559 transaction to the network | No — broadcast only, no readContract |
Operational consequences:
- Treat
/quote/*as a thin pricing API. It can be public, cached, and oracle-style. Don't add transactional side effects there. - The actual on-chain Brokerbot interaction is
RealUnitBlockchainService.getBrokerbotSellPrice(brokerbotAddress, shares)(viemreadContract). It is invoked bygetSellPaymentInfo,createSellUnsignedTransactionsandconfirmSell— i.e. everyPUT /sell*route except/broadcast. Anything that names the smart contract directly (getBrokerbot…,brokerbotAddress) should stay scoped to that on-chain path. - The legacy
/brokerbot/*endpoints aredeprecated: truemirrors of the/quote/*ones. Don't add new functionality there.
The endpoint that tells the client what to do to RealUnit-register the connected wallet historically lived under /v1/realunit/wallet/status. That naming is misleading: the resource being described is the user's Aktionariat registration, not a generic wallet status — and clients never ask "what is the wallet's status?", they ask "what do I need to do to be RealUnit-registered?". The canonical path is now /v1/realunit/registration; the legacy path is kept as a deprecated: true mirror.
| Old | New |
|---|---|
GET /v1/realunit/wallet/status |
GET /v1/realunit/registration |
RealUnitWalletStatusDto |
RealUnitRegistrationInfoDto |
RealUnitService.getAddressWalletStatus(...) |
RealUnitService.getRegistrationInfo(...) |
Operational consequence: treat /wallet/status as deprecated; consume state from the new /registration endpoint; the legacy path is kept for backwards compatibility on existing clients only.
// Return undefined when data is intentionally not loaded (permissions/config)
// Return [] when data is loaded but empty
// This lets the frontend distinguish "not shown" from "no entries yet"
return hasPermission ? await this.getData() : undefined;Capabilities tell the client (a) whether an action is currently available to the user, and, for discoverable actions, (b) what prerequisite is missing. They make the backend the single source of truth for business rules so the client never has to encode them.
Synthesised from the
#3733 → #3761 → #3767(closed) → #3772(merged 2026-05-26) review
sequence with @davidleomay. Read these eight rules before adding any
new capability flag.
| Action type | Schema |
|---|---|
| Hide-able (e.g. Edit button — UI just hides/disables it when forbidden) | canEditName: boolean |
| Discoverable (tile MUST stay visible; user is guided through a prerequisite) | createSupportTicket: { available, missingPrerequisite? } |
Don't mix the two. Hide-able stays bool. Discoverable needs a
discriminator so the client knows which prerequisite to render.
Endpoint paths don't change per user. Never ship { method, path }
objects on every /v2/user response. Instead:
@ApiBadRequestResponse({
description: 'Includes prerequisite failures (e.g. missing email — register first via POST /v1/realunit/register/email) and request-validation failures.',
})
async createIssue(...) { ... }Consumers read Swagger for the static remediation path; the dynamic
per-user signal stays on /v2/user.
Don't ship "future-proof" enum values without a current backend gate that emits them. Reviewers flag this as dead code (correctly).
// Now:
export enum MissingPrerequisite {
EMAIL = 'Email',
}
// Add PHONE = 'Phone' the day a phone-prerequisite gate actually
// throws — additive, backwards-compatible.The mapper return type is the discriminated union; the DTO class stays for Swagger because NestJS decorators don't model TS unions cleanly.
export type CreateSupportTicketCapability =
| { available: true }
| { available: false; missingPrerequisite: MissingPrerequisite };
private static computeCreateSupportTicketCapability(userData: UserData): CreateSupportTicketCapability {
return userData.mail
? { available: true }
: { available: false, missingPrerequisite: MissingPrerequisite.EMAIL };
}The DTO class is the structural supertype — Swagger stays permissive, the mapper is type-safe.
If the tile/button stays unconditionally visible (so the user can discover the action), the backend MUST expose pre-tap capability info. Letting the app attempt the action and react to a 400 is forbidden, because:
- the user fills out the form for nothing,
- the app would have to parse the error body to decide control flow (anti-pattern),
- form data is lost or navigation becomes fragile.
Capability on /v2/user, checked before pushNamed(targetRoute).
Backend PR (this repo) lands on develop first; the consumer's app PR
follows within one week and deletes the local logic in the same PR.
If you deviate from a reviewer's suggestion, post a comment on the PR that names:
- what you adopted,
- where you diverged,
- the UX or architectural reason for diverging,
- alternatives considered and rejected (with quantitative arguments where possible — LOC, payload bytes, round-trips).
Reference template: #3772 (comment)
The client should never encode "mail required for support". The client
encodes "if missingPrerequisite: 'Email' arrives, render the email
form". That's UI-component mapping, not business-rule replication.
Anti-patterns that violate this:
if (user.mail == null) hideTile()— capability flag missingif (response.error === 'AmountTooLow') showXyz()— typed error code missingif (kyc.level < 30) showWarning()— capability flag missing- Client orchestrates N sequential calls — workflow endpoint missing
When a reviewer says "too complex", test the reduced proposal against every explicit UX requirement first:
- Does the reduced solution satisfy all explicit product/UX needs? → adopt it.
- If not, formulate the minimum structural addition that closes the gap, and post the trade-off comment from rule #6.
- Never hold on to the original design just because it's already built.
Concrete example from this codebase: PR #3767 proposed a 4-DTO capability tree (170 LOC); reviewer pushed for the Swagger-only approach; the minimum compromise that preserved the pre-tap UX requirement was a single DTO with two fields (PR #3772, 91 LOC, ~50% reduction).
- The consumer-side mirror of these rules lives in
DFXswiss/realunit-app:CONTRIBUTING.mdunder "API as Decision Authority". - The phased roll-out plan lives in
DFXswiss/realunit-app:docs/api-authority-plan.md.
- Jest with
--silentby default (npm test) - Tests live next to the subdomain they cover
- Mock with
Object.assign(new Entity(), { ...values })ormockImplementation - Use
mockResolvedValue(value)notmockResolvedValue(Promise.resolve(value)) - Entity tests go in
entity.spec.ts, service tests inservice.spec.ts - Entity tests don't need module/service setup
- Remove dead/unused code proactively
- Delete entire feature modules when obsolete — no code stays "for later"
- Move code to the correct domain when misplaced
| Anti-Pattern | Correct Pattern |
| ------------------------------------------ | ------------------------------------------------- | ------------- | ---- |
| Magic booleans: getPrice(from, to, true) | Use enum: getPrice(from, to, PriceValidity.ANY) |
| Hardcoded values: fee = 5 | Config object or DB setting |
| else if after return/throw | Separate if blocks with early returns |
| console.log in production code | Use this.logger.* with proper levels |
| parseInt(id) | +id |
| filter()[0] | find() |
| forEach + push | .map() |
| | | for nullish | ?? |
| == null ? x : y | ?? x |
| Loading all then filtering in JS | SQL WHERE clause |
| any type | Proper typed interface/class |
| string for enum values | Typed enum |
| @Interval(60000) | @DfxCron(CronExpression.EVERY_MINUTE) |
| eager: true everywhere | Explicit relation loading |
| Providing service in multiple modules | Single module, import from there |
| JSON.stringify(JSON.parse(...)) | Unnecessary — remove |
| Default parameters hiding intent | Explicit parameters |
| forwardRef when avoidable | Restructure module dependencies |
| Base64 encode then immediately decode | Pass the buffer directly |
| Parameter threading through 4+ layers | Context/strategy object resolved once |
| Method-forwarding without added logic | Call the sub-service directly |
| Disabling ESLint rules without reason | Fix the code instead |