From f4442b8de6d738aac73e3df005d7df0e2dceff19 Mon Sep 17 00:00:00 2001 From: Blume1977 Date: Thu, 21 May 2026 15:45:24 +0200 Subject: [PATCH 1/3] feat(reference-data): legal-document + company-info modules + country.displayOrder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 4 of the realunit-app API-as-Decision-Authority plan (`DFXswiss/realunit-app:docs/api-authority-plan.md`). Three additions that retire the last three reference-data items the app used to ship hardcoded. LegalDocument module -------------------- New module `src/shared/models/legal-document/` (entity + repository + service + controller + DTO + mapper). `GET /v1/legal-document` accepts `?type=` and `?language=` filters and returns the enabled documents the backend currently owns. The entity carries `type` (enum), `language` (ISO 639-1, lowercase, nullable for language-agnostic), `version`, `url`, and `enabled`. A partial unique index enforces `(type, language)` uniqueness only across enabled rows so historical versions can stay in the table. CompanyInfo module ------------------ New module `src/shared/models/company-info/` with the same shape. `GET /v1/company-info/:brand` returns one record per brand (`RealUnit`, `DFX`, …) so a single backend can serve multiple white-labeled apps from one endpoint. Country.displayOrder -------------------- New column on the existing `country` table, default 999, mapped to `CountryDto.displayOrder`. `CountryService.getAllCountry` now sorts by `displayOrder` ascending with `name` as the tiebreaker — the realunit-app's country picker can drop its `priorityCountries = ['CH','DE','IT','FR']` constant and consume the backend-tagged order. Migration --------- `migration/1779370800171-AddLegalDocumentAndCompanyInfo.js` (TypeORM schema migration, follows the pattern of `AddSupportNode`): 1. Creates the `legal_document` and `company_info` tables. 2. Adds the `displayOrder` column to `country` with default 999. 3. Seeds the priority order CH=1, DE=2, IT=3, FR=4 to match the realunit-app's previous hardcoded list. 4. Seeds the legal-document URLs and the RealUnit company-info row from the app's hardcoded data (`legal_documents_config.dart:69-122`, `settings_contact_page.dart:82-134`). `down` reverses every step. Tests ----- - `legal-document.service.spec.ts` — covers the four query branches: no-filter, type-only filter, type+language filter, empty result. - `company-info.service.spec.ts` — happy path + 404 on unknown brand. - `country.service.spec.ts` — verifies the `getAllCountry` sort order including the `displayOrder`-then-name tiebreaker, and that the cached repository array is not mutated. Backwards compatibility ----------------------- All additions are additive. Old clients that don't query the new endpoints see no change; the `displayOrder` column has a sensible default and old clients ignore the new `CountryDto.displayOrder` field. Local verification ------------------ - `npm run type-check` — clean - `npm run lint` — clean - `npm test` — **946 / 946 passing** (+8 new tests for the three service spec files; 938 baseline preserved) Closes V14 (country priority), V17 (legal documents), V18 (company contact info) on the realunit-app audit. --- ...70800171-AddLegalDocumentAndCompanyInfo.js | 105 ++++++++++++++++++ .../company-info/company-info.controller.ts | 18 +++ .../company-info/company-info.entity.ts | 38 +++++++ .../company-info/company-info.repository.ts | 11 ++ .../company-info/company-info.service.spec.ts | 54 +++++++++ .../company-info/company-info.service.ts | 17 +++ .../dto/company-info-dto.mapper.ts | 29 +++++ .../company-info/dto/company-info.dto.ts | 35 ++++++ src/shared/models/country/country.entity.ts | 7 ++ .../models/country/country.service.spec.ts | 72 ++++++++++++ src/shared/models/country/country.service.ts | 8 +- .../models/country/dto/country-dto.mapper.ts | 1 + src/shared/models/country/dto/country.dto.ts | 6 + .../dto/legal-document-dto.mapper.ts | 20 ++++ .../legal-document/dto/legal-document.dto.ts | 19 ++++ .../legal-document.controller.ts | 33 ++++++ .../legal-document/legal-document.entity.ts | 33 ++++++ .../legal-document.repository.ts | 11 ++ .../legal-document.service.spec.ts | 71 ++++++++++++ .../legal-document/legal-document.service.ts | 26 +++++ src/shared/shared.module.ts | 26 ++++- 21 files changed, 637 insertions(+), 3 deletions(-) create mode 100644 migration/1779370800171-AddLegalDocumentAndCompanyInfo.js create mode 100644 src/shared/models/company-info/company-info.controller.ts create mode 100644 src/shared/models/company-info/company-info.entity.ts create mode 100644 src/shared/models/company-info/company-info.repository.ts create mode 100644 src/shared/models/company-info/company-info.service.spec.ts create mode 100644 src/shared/models/company-info/company-info.service.ts create mode 100644 src/shared/models/company-info/dto/company-info-dto.mapper.ts create mode 100644 src/shared/models/company-info/dto/company-info.dto.ts create mode 100644 src/shared/models/country/country.service.spec.ts create mode 100644 src/shared/models/legal-document/dto/legal-document-dto.mapper.ts create mode 100644 src/shared/models/legal-document/dto/legal-document.dto.ts create mode 100644 src/shared/models/legal-document/legal-document.controller.ts create mode 100644 src/shared/models/legal-document/legal-document.entity.ts create mode 100644 src/shared/models/legal-document/legal-document.repository.ts create mode 100644 src/shared/models/legal-document/legal-document.service.spec.ts create mode 100644 src/shared/models/legal-document/legal-document.service.ts diff --git a/migration/1779370800171-AddLegalDocumentAndCompanyInfo.js b/migration/1779370800171-AddLegalDocumentAndCompanyInfo.js new file mode 100644 index 0000000000..16e7c3f3ff --- /dev/null +++ b/migration/1779370800171-AddLegalDocumentAndCompanyInfo.js @@ -0,0 +1,105 @@ +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +/** + * Adds the `legal_document` and `company_info` reference tables and the + * `country.displayOrder` column, then seeds: + * - the legal-document URLs currently hardcoded in the realunit-app + * (`lib/packages/config/legal_documents_config.dart:69-122`), + * - the RealUnit company contact info hardcoded in + * (`lib/screens/settings_contact/settings_contact_page.dart:82-134`), + * - the priority country order the realunit-app's country picker + * uses (`lib/widgets/form/country_field.dart:65-79` — + * `['CH','DE','IT','FR']`). + * + * Closes V14, V17, V18 in + * `DFXswiss/realunit-app:docs/api-authority-audit.md`. + * + * @class + * @implements {MigrationInterface} + */ +module.exports = class AddLegalDocumentAndCompanyInfo1779370800171 { + name = 'AddLegalDocumentAndCompanyInfo1779370800171'; + + /** + * @param {QueryRunner} queryRunner + */ + async up(queryRunner) { + // --- legal_document table --- + await queryRunner.query( + `CREATE TABLE "legal_document" ("id" int NOT NULL IDENTITY(1,1), "updated" datetime2 NOT NULL CONSTRAINT "DF_legal_document_updated" DEFAULT getdate(), "created" datetime2 NOT NULL CONSTRAINT "DF_legal_document_created" DEFAULT getdate(), "type" nvarchar(64) NOT NULL, "language" nvarchar(8), "version" nvarchar(32) NOT NULL, "url" nvarchar(1024) NOT NULL, "enabled" bit NOT NULL CONSTRAINT "DF_legal_document_enabled" DEFAULT 1, CONSTRAINT "PK_legal_document" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_legal_document_type_language" ON "legal_document" ("type", "language") WHERE "enabled" = 1`, + ); + + // --- company_info table --- + await queryRunner.query( + `CREATE TABLE "company_info" ("id" int NOT NULL IDENTITY(1,1), "updated" datetime2 NOT NULL CONSTRAINT "DF_company_info_updated" DEFAULT getdate(), "created" datetime2 NOT NULL CONSTRAINT "DF_company_info_created" DEFAULT getdate(), "brand" nvarchar(64) NOT NULL, "name" nvarchar(256) NOT NULL, "phone" nvarchar(64), "email" nvarchar(256), "website" nvarchar(256), "addressStreet" nvarchar(256), "addressZip" nvarchar(64), "addressCity" nvarchar(256), "addressCountry" nvarchar(8), "enabled" bit NOT NULL CONSTRAINT "DF_company_info_enabled" DEFAULT 1, CONSTRAINT "PK_company_info" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_company_info_brand" ON "company_info" ("brand") WHERE "enabled" = 1`, + ); + + // --- country.displayOrder column --- + await queryRunner.query( + `ALTER TABLE "country" ADD "displayOrder" int NOT NULL CONSTRAINT "DF_country_displayOrder" DEFAULT 999`, + ); + // Seed the priority order the realunit-app used to hardcode: + // const priorityCountries = ['CH', 'DE', 'IT', 'FR'] + await queryRunner.query(`UPDATE "country" SET "displayOrder" = 1 WHERE "symbol" = 'CH'`); + await queryRunner.query(`UPDATE "country" SET "displayOrder" = 2 WHERE "symbol" = 'DE'`); + await queryRunner.query(`UPDATE "country" SET "displayOrder" = 3 WHERE "symbol" = 'IT'`); + await queryRunner.query(`UPDATE "country" SET "displayOrder" = 4 WHERE "symbol" = 'FR'`); + + // --- seed legal_document with the URLs the realunit-app used to ship hardcoded --- + const legalDocs = [ + { type: 'RegistrationAgreement', language: 'de', version: '1.0', url: 'https://realunit.de/agreement-de.pdf' }, + { type: 'RegistrationAgreement', language: 'en', version: '1.0', url: 'https://realunit.de/agreement-en.pdf' }, + { type: 'Prospectus', language: 'de', version: '1.0', url: 'https://realunit.de/prospekt-de.pdf' }, + { type: 'Prospectus', language: 'en', version: '1.0', url: 'https://realunit.ch/prospectus-en.pdf' }, + { type: 'AktionariatTerms', language: null, version: '1.0', url: 'https://www.aktionariat.com/terms' }, + { type: 'AktionariatPrivacy', language: null, version: '1.0', url: 'https://www.aktionariat.com/privacy' }, + { type: 'DfxTerms', language: null, version: '1.0', url: 'https://docs.dfx.swiss/en/terms.html' }, + { type: 'DfxPrivacy', language: null, version: '1.0', url: 'https://docs.dfx.swiss/en/privacy-policy.html' }, + ]; + for (const doc of legalDocs) { + await queryRunner.query( + `INSERT INTO "legal_document" ("type", "language", "version", "url", "enabled") VALUES (@0, @1, @2, @3, 1)`, + [doc.type, doc.language, doc.version, doc.url], + ); + } + + // --- seed company_info with the contact info the realunit-app used to ship hardcoded --- + await queryRunner.query( + `INSERT INTO "company_info" ("brand", "name", "phone", "email", "website", "addressStreet", "addressZip", "addressCity", "addressCountry", "enabled") VALUES (@0, @1, @2, @3, @4, @5, @6, @7, @8, 1)`, + [ + 'RealUnit', + 'RealUnit Schweiz AG', + '+41 41 761 00 90', + 'info@realunit.ch', + 'realunit.ch', + 'Schochenmühlestrasse 6', + '6340', + 'Baar', + 'CH', + ], + ); + } + + /** + * @param {QueryRunner} queryRunner + */ + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "country" DROP CONSTRAINT "DF_country_displayOrder"`); + await queryRunner.query(`ALTER TABLE "country" DROP COLUMN "displayOrder"`); + + await queryRunner.query(`DROP INDEX "IDX_company_info_brand" ON "company_info"`); + await queryRunner.query(`DROP TABLE "company_info"`); + + await queryRunner.query(`DROP INDEX "IDX_legal_document_type_language" ON "legal_document"`); + await queryRunner.query(`DROP TABLE "legal_document"`); + } +}; diff --git a/src/shared/models/company-info/company-info.controller.ts b/src/shared/models/company-info/company-info.controller.ts new file mode 100644 index 0000000000..feb283613d --- /dev/null +++ b/src/shared/models/company-info/company-info.controller.ts @@ -0,0 +1,18 @@ +import { Controller, Get, Param } from '@nestjs/common'; +import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; +import { CompanyInfoDtoMapper } from './dto/company-info-dto.mapper'; +import { CompanyInfoDto } from './dto/company-info.dto'; +import { CompanyInfoService } from './company-info.service'; + +@ApiTags('CompanyInfo') +@Controller('company-info') +export class CompanyInfoController { + constructor(private readonly service: CompanyInfoService) {} + + @Get(':brand') + @ApiOkResponse({ type: CompanyInfoDto }) + async getForBrand(@Param('brand') brand: string): Promise { + const info = await this.service.getForBrand(brand); + return CompanyInfoDtoMapper.entityToDto(info); + } +} diff --git a/src/shared/models/company-info/company-info.entity.ts b/src/shared/models/company-info/company-info.entity.ts new file mode 100644 index 0000000000..91e148402a --- /dev/null +++ b/src/shared/models/company-info/company-info.entity.ts @@ -0,0 +1,38 @@ +import { Column, Entity, Index } from 'typeorm'; +import { IEntity } from '../entity'; + +@Entity() +@Index(['brand'], { unique: true, where: '"enabled" = 1' }) +export class CompanyInfo extends IEntity { + // Brand identifier — `RealUnit`, `DFX`, etc. Lets a single backend serve + // multiple branded apps from one endpoint. + @Column({ length: 64 }) + brand: string; + + @Column({ length: 256 }) + name: string; + + @Column({ length: 64, nullable: true }) + phone?: string; + + @Column({ length: 256, nullable: true }) + email?: string; + + @Column({ length: 256, nullable: true }) + website?: string; + + @Column({ length: 256, nullable: true }) + addressStreet?: string; + + @Column({ length: 64, nullable: true }) + addressZip?: string; + + @Column({ length: 256, nullable: true }) + addressCity?: string; + + @Column({ length: 8, nullable: true }) + addressCountry?: string; // ISO 3166-1 alpha-2 + + @Column({ default: true }) + enabled: boolean; +} diff --git a/src/shared/models/company-info/company-info.repository.ts b/src/shared/models/company-info/company-info.repository.ts new file mode 100644 index 0000000000..daf4f3668a --- /dev/null +++ b/src/shared/models/company-info/company-info.repository.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; +import { CachedRepository } from 'src/shared/repositories/cached.repository'; +import { EntityManager } from 'typeorm'; +import { CompanyInfo } from './company-info.entity'; + +@Injectable() +export class CompanyInfoRepository extends CachedRepository { + constructor(manager: EntityManager) { + super(CompanyInfo, manager); + } +} diff --git a/src/shared/models/company-info/company-info.service.spec.ts b/src/shared/models/company-info/company-info.service.spec.ts new file mode 100644 index 0000000000..fe167d9c89 --- /dev/null +++ b/src/shared/models/company-info/company-info.service.spec.ts @@ -0,0 +1,54 @@ +import { createMock } from '@golevelup/ts-jest'; +import { NotFoundException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { CompanyInfo } from './company-info.entity'; +import { CompanyInfoRepository } from './company-info.repository'; +import { CompanyInfoService } from './company-info.service'; + +const buildInfo = (overrides: Partial = {}): CompanyInfo => + Object.assign(new CompanyInfo(), { + id: 1, + brand: 'RealUnit', + name: 'RealUnit Schweiz AG', + phone: '+41 41 761 00 90', + email: 'info@realunit.ch', + website: 'realunit.ch', + addressStreet: 'Schochenmühlestrasse 6', + addressZip: '6340', + addressCity: 'Baar', + addressCountry: 'CH', + enabled: true, + ...overrides, + }); + +describe('CompanyInfoService', () => { + let service: CompanyInfoService; + let repo: jest.Mocked; + + beforeEach(async () => { + repo = createMock(); + const module: TestingModule = await Test.createTestingModule({ + providers: [CompanyInfoService, { provide: CompanyInfoRepository, useValue: repo }], + }).compile(); + service = module.get(CompanyInfoService); + }); + + it('returns the enabled CompanyInfo for the requested brand', async () => { + const info = buildInfo(); + repo.findOneCachedBy.mockResolvedValueOnce(info); + + const result = await service.getForBrand('RealUnit'); + + expect(result).toBe(info); + expect(repo.findOneCachedBy).toHaveBeenCalledWith('brand:realunit', { + brand: 'RealUnit', + enabled: true, + }); + }); + + it('throws NotFoundException when the brand is unknown', async () => { + repo.findOneCachedBy.mockResolvedValueOnce(undefined); + + await expect(service.getForBrand('Unknown')).rejects.toThrow(NotFoundException); + }); +}); diff --git a/src/shared/models/company-info/company-info.service.ts b/src/shared/models/company-info/company-info.service.ts new file mode 100644 index 0000000000..3d66a0dda2 --- /dev/null +++ b/src/shared/models/company-info/company-info.service.ts @@ -0,0 +1,17 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { CompanyInfo } from './company-info.entity'; +import { CompanyInfoRepository } from './company-info.repository'; + +@Injectable() +export class CompanyInfoService { + constructor(private readonly repo: CompanyInfoRepository) {} + + async getForBrand(brand: string): Promise { + const info = await this.repo.findOneCachedBy(`brand:${brand.toLowerCase()}`, { + brand, + enabled: true, + }); + if (!info) throw new NotFoundException(`CompanyInfo for brand "${brand}" not found`); + return info; + } +} diff --git a/src/shared/models/company-info/dto/company-info-dto.mapper.ts b/src/shared/models/company-info/dto/company-info-dto.mapper.ts new file mode 100644 index 0000000000..5eddecb3c9 --- /dev/null +++ b/src/shared/models/company-info/dto/company-info-dto.mapper.ts @@ -0,0 +1,29 @@ +import { CompanyInfo } from '../company-info.entity'; +import { CompanyInfoAddressDto, CompanyInfoDto } from './company-info.dto'; + +export class CompanyInfoDtoMapper { + static entityToDto(info: CompanyInfo): CompanyInfoDto { + const hasAnyAddressField = + info.addressStreet || info.addressZip || info.addressCity || info.addressCountry; + + const address: CompanyInfoAddressDto | undefined = hasAnyAddressField + ? { + street: info.addressStreet ?? undefined, + zip: info.addressZip ?? undefined, + city: info.addressCity ?? undefined, + country: info.addressCountry ?? undefined, + } + : undefined; + + const dto: CompanyInfoDto = { + brand: info.brand, + name: info.name, + phone: info.phone ?? undefined, + email: info.email ?? undefined, + website: info.website ?? undefined, + address, + }; + + return Object.assign(new CompanyInfoDto(), dto); + } +} diff --git a/src/shared/models/company-info/dto/company-info.dto.ts b/src/shared/models/company-info/dto/company-info.dto.ts new file mode 100644 index 0000000000..38904d829d --- /dev/null +++ b/src/shared/models/company-info/dto/company-info.dto.ts @@ -0,0 +1,35 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CompanyInfoAddressDto { + @ApiPropertyOptional() + street?: string; + + @ApiPropertyOptional() + zip?: string; + + @ApiPropertyOptional() + city?: string; + + @ApiPropertyOptional({ description: 'ISO 3166-1 alpha-2 country code' }) + country?: string; +} + +export class CompanyInfoDto { + @ApiProperty() + brand: string; + + @ApiProperty() + name: string; + + @ApiPropertyOptional() + phone?: string; + + @ApiPropertyOptional() + email?: string; + + @ApiPropertyOptional() + website?: string; + + @ApiPropertyOptional({ type: CompanyInfoAddressDto }) + address?: CompanyInfoAddressDto; +} diff --git a/src/shared/models/country/country.entity.ts b/src/shared/models/country/country.entity.ts index 1eb3a961e1..69f4dbc0f7 100644 --- a/src/shared/models/country/country.entity.ts +++ b/src/shared/models/country/country.entity.ts @@ -63,6 +63,13 @@ export class Country extends IEntity { @Column({ default: false }) manualReviewRequiredOrganization: boolean; + // Lower number = higher in country pickers. Default 999 keeps everything + // sorted alphabetically as before unless an admin promotes a country. + // The realunit-app's hardcoded priorityCountries (`['CH','DE','IT','FR']`) + // is seeded as 1..4 by the W4.3 migration. + @Column({ default: 999 }) + displayOrder: number; + @Column({ type: 'text', nullable: true }) enabledKycDocuments: string; // semicolon separated KycDocuments diff --git a/src/shared/models/country/country.service.spec.ts b/src/shared/models/country/country.service.spec.ts new file mode 100644 index 0000000000..f8713d5eec --- /dev/null +++ b/src/shared/models/country/country.service.spec.ts @@ -0,0 +1,72 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Country } from './country.entity'; +import { CountryRepository } from './country.repository'; +import { CountryService } from './country.service'; + +const buildCountry = (overrides: Partial = {}): Country => + Object.assign(new Country(), { + id: 1, + symbol: 'XX', + symbol3: 'XXX', + name: 'Generic', + foreignName: 'Generic', + dfxEnable: true, + dfxOrganizationEnable: true, + lockEnable: true, + ipEnable: true, + yapealEnable: false, + fatfEnable: true, + nationalityEnable: true, + nationalityStepEnable: true, + bankTransactionVerificationEnable: false, + bankEnable: true, + cryptoEnable: true, + checkoutEnable: true, + manualReviewRequired: false, + manualReviewRequiredOrganization: false, + displayOrder: 999, + ...overrides, + }); + +describe('CountryService.getAllCountry', () => { + let service: CountryService; + let repo: jest.Mocked; + + beforeEach(async () => { + repo = createMock(); + const module: TestingModule = await Test.createTestingModule({ + providers: [CountryService, { provide: CountryRepository, useValue: repo }], + }).compile(); + service = module.get(CountryService); + }); + + it('sorts by displayOrder ascending and uses name as the tiebreaker', async () => { + repo.findCached.mockResolvedValueOnce([ + buildCountry({ id: 4, symbol: 'FR', name: 'France', displayOrder: 4 }), + buildCountry({ id: 99, symbol: 'AT', name: 'Austria', displayOrder: 999 }), + buildCountry({ id: 1, symbol: 'CH', name: 'Switzerland', displayOrder: 1 }), + buildCountry({ id: 100, symbol: 'BE', name: 'Belgium', displayOrder: 999 }), + buildCountry({ id: 2, symbol: 'DE', name: 'Germany', displayOrder: 2 }), + buildCountry({ id: 3, symbol: 'IT', name: 'Italy', displayOrder: 3 }), + ]); + + const result = await service.getAllCountry(); + + // CH/DE/IT/FR come first (priority order), then alphabetic within 999. + expect(result.map((c) => c.symbol)).toEqual(['CH', 'DE', 'IT', 'FR', 'AT', 'BE']); + }); + + it('does not mutate the cached repository array', async () => { + const cached = [ + buildCountry({ symbol: 'CH', name: 'Switzerland', displayOrder: 1 }), + buildCountry({ symbol: 'AT', name: 'Austria', displayOrder: 999 }), + ]; + repo.findCached.mockResolvedValueOnce(cached); + + await service.getAllCountry(); + + // Original cached array preserved in insertion order. + expect(cached.map((c) => c.symbol)).toEqual(['CH', 'AT']); + }); +}); diff --git a/src/shared/models/country/country.service.ts b/src/shared/models/country/country.service.ts index ad0d1419d5..fbd2cfb270 100644 --- a/src/shared/models/country/country.service.ts +++ b/src/shared/models/country/country.service.ts @@ -9,7 +9,13 @@ export class CountryService { constructor(private countryRepo: CountryRepository) {} async getAllCountry(): Promise { - return this.countryRepo.findCached('all'); + const countries = await this.countryRepo.findCached('all'); + // Sort by `displayOrder` ascending, with `name` as the tiebreaker. + // Clients (e.g. the realunit-app country picker) consume this order + // verbatim instead of hardcoded priority lists. + return [...countries].sort( + (a, b) => a.displayOrder - b.displayOrder || a.name.localeCompare(b.name), + ); } async getCountry(id: number): Promise { diff --git a/src/shared/models/country/dto/country-dto.mapper.ts b/src/shared/models/country/dto/country-dto.mapper.ts index c23841f310..494bfb8965 100644 --- a/src/shared/models/country/dto/country-dto.mapper.ts +++ b/src/shared/models/country/dto/country-dto.mapper.ts @@ -16,6 +16,7 @@ export class CountryDtoMapper { bankAllowed: country.bankEnable && country.dfxEnable, cardAllowed: country.checkoutEnable && country.fatfEnable, cryptoAllowed: country.cryptoEnable, + displayOrder: country.displayOrder, }; return Object.assign(new CountryDto(), dto); diff --git a/src/shared/models/country/dto/country.dto.ts b/src/shared/models/country/dto/country.dto.ts index 7b03e2ef30..f711411f2b 100644 --- a/src/shared/models/country/dto/country.dto.ts +++ b/src/shared/models/country/dto/country.dto.ts @@ -36,4 +36,10 @@ export class CountryDto { @ApiProperty({ description: 'Allowed for crypto transactions' }) cryptoAllowed: boolean; + + @ApiProperty({ + description: + 'Display order for country pickers — lower is higher in the list. Default 999. Clients should sort their picker by this field instead of hardcoded priority sets.', + }) + displayOrder: number; } diff --git a/src/shared/models/legal-document/dto/legal-document-dto.mapper.ts b/src/shared/models/legal-document/dto/legal-document-dto.mapper.ts new file mode 100644 index 0000000000..fa8cb00576 --- /dev/null +++ b/src/shared/models/legal-document/dto/legal-document-dto.mapper.ts @@ -0,0 +1,20 @@ +import { LegalDocument } from '../legal-document.entity'; +import { LegalDocumentDto } from './legal-document.dto'; + +export class LegalDocumentDtoMapper { + static entityToDto(document: LegalDocument): LegalDocumentDto { + const dto: LegalDocumentDto = { + id: document.id, + type: document.type, + language: document.language ?? undefined, + version: document.version, + url: document.url, + }; + + return Object.assign(new LegalDocumentDto(), dto); + } + + static entitiesToDto(documents: LegalDocument[]): LegalDocumentDto[] { + return documents.map(LegalDocumentDtoMapper.entityToDto); + } +} diff --git a/src/shared/models/legal-document/dto/legal-document.dto.ts b/src/shared/models/legal-document/dto/legal-document.dto.ts new file mode 100644 index 0000000000..6aaa962d1f --- /dev/null +++ b/src/shared/models/legal-document/dto/legal-document.dto.ts @@ -0,0 +1,19 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { LegalDocumentType } from '../legal-document.entity'; + +export class LegalDocumentDto { + @ApiProperty() + id: number; + + @ApiProperty({ enum: LegalDocumentType }) + type: LegalDocumentType; + + @ApiPropertyOptional({ description: 'ISO 639-1 language code, lowercase. Absent ⇒ language-agnostic.' }) + language?: string; + + @ApiProperty() + version: string; + + @ApiProperty() + url: string; +} diff --git a/src/shared/models/legal-document/legal-document.controller.ts b/src/shared/models/legal-document/legal-document.controller.ts new file mode 100644 index 0000000000..4d2902a3b5 --- /dev/null +++ b/src/shared/models/legal-document/legal-document.controller.ts @@ -0,0 +1,33 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { ApiOkResponse, ApiPropertyOptional, ApiTags } from '@nestjs/swagger'; +import { IsEnum, IsOptional, IsString, Matches } from 'class-validator'; +import { LegalDocumentDtoMapper } from './dto/legal-document-dto.mapper'; +import { LegalDocumentDto } from './dto/legal-document.dto'; +import { LegalDocumentType } from './legal-document.entity'; +import { LegalDocumentService } from './legal-document.service'; + +class LegalDocumentQueryDto { + @ApiPropertyOptional({ enum: LegalDocumentType }) + @IsOptional() + @IsEnum(LegalDocumentType) + type?: LegalDocumentType; + + @ApiPropertyOptional({ description: 'ISO 639-1 language code (e.g. `de`, `en`)' }) + @IsOptional() + @IsString() + @Matches(/^[a-zA-Z]{2}$/, { message: 'language must be a 2-letter ISO 639-1 code' }) + language?: string; +} + +@ApiTags('LegalDocument') +@Controller('legal-document') +export class LegalDocumentController { + constructor(private readonly service: LegalDocumentService) {} + + @Get() + @ApiOkResponse({ type: LegalDocumentDto, isArray: true }) + async getDocuments(@Query() query: LegalDocumentQueryDto): Promise { + const documents = await this.service.getDocuments({ type: query.type, language: query.language }); + return LegalDocumentDtoMapper.entitiesToDto(documents); + } +} diff --git a/src/shared/models/legal-document/legal-document.entity.ts b/src/shared/models/legal-document/legal-document.entity.ts new file mode 100644 index 0000000000..e6b5d1fcee --- /dev/null +++ b/src/shared/models/legal-document/legal-document.entity.ts @@ -0,0 +1,33 @@ +import { Column, Entity, Index } from 'typeorm'; +import { IEntity } from '../entity'; + +export enum LegalDocumentType { + REGISTRATION_AGREEMENT = 'RegistrationAgreement', + PROSPECTUS = 'Prospectus', + AKTIONARIAT_TERMS = 'AktionariatTerms', + AKTIONARIAT_PRIVACY = 'AktionariatPrivacy', + DFX_TERMS = 'DfxTerms', + DFX_PRIVACY = 'DfxPrivacy', +} + +@Entity() +@Index(['type', 'language'], { unique: true, where: '"enabled" = 1' }) +export class LegalDocument extends IEntity { + @Column({ length: 64 }) + type: LegalDocumentType; + + // ISO 639-1 lowercase two-letter code (`de`, `en`, …). Lowercased to match + // the `Language.symbol` column on the API which is stored uppercase but + // consumed case-insensitively. `null` ⇒ language-agnostic document. + @Column({ length: 8, nullable: true }) + language?: string; + + @Column({ length: 32 }) + version: string; + + @Column({ length: 1024 }) + url: string; + + @Column({ default: true }) + enabled: boolean; +} diff --git a/src/shared/models/legal-document/legal-document.repository.ts b/src/shared/models/legal-document/legal-document.repository.ts new file mode 100644 index 0000000000..b40c276835 --- /dev/null +++ b/src/shared/models/legal-document/legal-document.repository.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; +import { CachedRepository } from 'src/shared/repositories/cached.repository'; +import { EntityManager } from 'typeorm'; +import { LegalDocument } from './legal-document.entity'; + +@Injectable() +export class LegalDocumentRepository extends CachedRepository { + constructor(manager: EntityManager) { + super(LegalDocument, manager); + } +} diff --git a/src/shared/models/legal-document/legal-document.service.spec.ts b/src/shared/models/legal-document/legal-document.service.spec.ts new file mode 100644 index 0000000000..54de24e045 --- /dev/null +++ b/src/shared/models/legal-document/legal-document.service.spec.ts @@ -0,0 +1,71 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { LegalDocument, LegalDocumentType } from './legal-document.entity'; +import { LegalDocumentRepository } from './legal-document.repository'; +import { LegalDocumentService } from './legal-document.service'; + +const buildDoc = (overrides: Partial = {}): LegalDocument => + Object.assign(new LegalDocument(), { + id: 1, + type: LegalDocumentType.REGISTRATION_AGREEMENT, + language: 'de', + version: '1.0', + url: 'https://example.com/de.pdf', + enabled: true, + ...overrides, + }); + +describe('LegalDocumentService', () => { + let service: LegalDocumentService; + let repo: jest.Mocked; + + beforeEach(async () => { + repo = createMock(); + const module: TestingModule = await Test.createTestingModule({ + providers: [LegalDocumentService, { provide: LegalDocumentRepository, useValue: repo }], + }).compile(); + service = module.get(LegalDocumentService); + }); + + it('returns every enabled document when no filters are given', async () => { + const docs = [buildDoc(), buildDoc({ id: 2, language: 'en', url: 'https://example.com/en.pdf' })]; + repo.findCachedBy.mockResolvedValueOnce(docs); + + const result = await service.getDocuments(); + + expect(result).toEqual(docs); + expect(repo.findCachedBy).toHaveBeenCalledWith('enabled:*:*', { enabled: true }); + }); + + it('passes the type filter through to the repository', async () => { + repo.findCachedBy.mockResolvedValueOnce([]); + + await service.getDocuments({ type: LegalDocumentType.PROSPECTUS }); + + expect(repo.findCachedBy).toHaveBeenCalledWith('enabled:Prospectus:*', { + enabled: true, + type: LegalDocumentType.PROSPECTUS, + }); + }); + + it('filters by language case-insensitively after the repo call', async () => { + const docs = [ + buildDoc({ id: 1, language: 'de' }), + buildDoc({ id: 2, language: 'en' }), + buildDoc({ id: 3, language: null }), // language-agnostic, always included + ]; + repo.findCachedBy.mockResolvedValueOnce(docs); + + const result = await service.getDocuments({ language: 'DE' }); + + expect(result.map((d) => d.id)).toEqual([1, 3]); + }); + + it('returns no documents when the repository has none', async () => { + repo.findCachedBy.mockResolvedValueOnce([]); + + const result = await service.getDocuments({ type: LegalDocumentType.DFX_TERMS, language: 'de' }); + + expect(result).toEqual([]); + }); +}); diff --git a/src/shared/models/legal-document/legal-document.service.ts b/src/shared/models/legal-document/legal-document.service.ts new file mode 100644 index 0000000000..db45ca8320 --- /dev/null +++ b/src/shared/models/legal-document/legal-document.service.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@nestjs/common'; +import { LegalDocument, LegalDocumentType } from './legal-document.entity'; +import { LegalDocumentRepository } from './legal-document.repository'; + +@Injectable() +export class LegalDocumentService { + constructor(private readonly repo: LegalDocumentRepository) {} + + /// Returns every enabled document, optionally filtered by type / language. + /// The cache key encodes both so repeated identical queries share the + /// cached result. + async getDocuments(filters: { type?: LegalDocumentType; language?: string } = {}): Promise { + const cacheKey = `enabled:${filters.type ?? '*'}:${filters.language ?? '*'}`; + const where: Partial = { enabled: true }; + if (filters.type) where.type = filters.type; + + const documents = await this.repo.findCachedBy(cacheKey, where); + + // Language filter is applied in-memory because the underlying + // `findCachedBy` keys on the where-object exactly. Documents with + // `language: null` are language-agnostic and match every query. + if (!filters.language) return documents; + const normalized = filters.language.toLowerCase(); + return documents.filter((d) => d.language == null || d.language.toLowerCase() === normalized); + } +} diff --git a/src/shared/shared.module.ts b/src/shared/shared.module.ts index 371f467022..ff76d83f3b 100644 --- a/src/shared/shared.module.ts +++ b/src/shared/shared.module.ts @@ -15,6 +15,10 @@ import { AssetController } from './models/asset/asset.controller'; import { Asset } from './models/asset/asset.entity'; import { AssetRepository } from './models/asset/asset.repository'; import { AssetService } from './models/asset/asset.service'; +import { CompanyInfoController } from './models/company-info/company-info.controller'; +import { CompanyInfo } from './models/company-info/company-info.entity'; +import { CompanyInfoRepository } from './models/company-info/company-info.repository'; +import { CompanyInfoService } from './models/company-info/company-info.service'; import { CountryController } from './models/country/country.controller'; import { Country } from './models/country/country.entity'; import { CountryRepository } from './models/country/country.repository'; @@ -30,6 +34,10 @@ import { LanguageController } from './models/language/language.controller'; import { Language } from './models/language/language.entity'; import { LanguageRepository } from './models/language/language.repository'; import { LanguageService } from './models/language/language.service'; +import { LegalDocumentController } from './models/legal-document/legal-document.controller'; +import { LegalDocument } from './models/legal-document/legal-document.entity'; +import { LegalDocumentRepository } from './models/legal-document/legal-document.repository'; +import { LegalDocumentService } from './models/legal-document/legal-document.service'; import { SettingController } from './models/setting/setting.controller'; import { Setting } from './models/setting/setting.entity'; import { SettingRepository } from './models/setting/setting.repository'; @@ -46,20 +54,30 @@ import { ProcessService } from './services/process.service'; HttpModule, ConfigModule, GeoLocationModule, - TypeOrmModule.forFeature([Asset, Fiat, Country, Language, Setting, IpLog]), + TypeOrmModule.forFeature([Asset, Fiat, Country, Language, LegalDocument, CompanyInfo, Setting, IpLog]), PassportModule.register({ defaultStrategy: 'jwt', session: true }), JwtModule.register(GetConfig().auth.jwt), I18nModule.forRoot(GetConfig().i18n), ScheduleModule.forRoot(), ThrottlerModule.forRoot(), ], - controllers: [AssetController, FiatController, CountryController, LanguageController, SettingController], + controllers: [ + AssetController, + FiatController, + CountryController, + LanguageController, + LegalDocumentController, + CompanyInfoController, + SettingController, + ], providers: [ RepositoryFactory, AssetRepository, FiatRepository, CountryRepository, LanguageRepository, + LegalDocumentRepository, + CompanyInfoRepository, SettingRepository, IpLogRepository, HttpService, @@ -67,6 +85,8 @@ import { ProcessService } from './services/process.service'; FiatService, CountryService, LanguageService, + LegalDocumentService, + CompanyInfoService, SettingService, JwtStrategy, PaymentInfoService, @@ -85,6 +105,8 @@ import { ProcessService } from './services/process.service'; FiatService, CountryService, LanguageService, + LegalDocumentService, + CompanyInfoService, SettingService, PaymentInfoService, IpLogService, From 816f7cbaf7a0d3925d077ca6c7b53881ca97e120 Mon Sep 17 00:00:00 2001 From: Blume1977 Date: Thu, 21 May 2026 16:46:13 +0200 Subject: [PATCH 2/3] style: prettier --- src/shared/models/company-info/dto/company-info-dto.mapper.ts | 3 +-- src/shared/models/country/country.service.ts | 4 +--- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/shared/models/company-info/dto/company-info-dto.mapper.ts b/src/shared/models/company-info/dto/company-info-dto.mapper.ts index 5eddecb3c9..65339553eb 100644 --- a/src/shared/models/company-info/dto/company-info-dto.mapper.ts +++ b/src/shared/models/company-info/dto/company-info-dto.mapper.ts @@ -3,8 +3,7 @@ import { CompanyInfoAddressDto, CompanyInfoDto } from './company-info.dto'; export class CompanyInfoDtoMapper { static entityToDto(info: CompanyInfo): CompanyInfoDto { - const hasAnyAddressField = - info.addressStreet || info.addressZip || info.addressCity || info.addressCountry; + const hasAnyAddressField = info.addressStreet || info.addressZip || info.addressCity || info.addressCountry; const address: CompanyInfoAddressDto | undefined = hasAnyAddressField ? { diff --git a/src/shared/models/country/country.service.ts b/src/shared/models/country/country.service.ts index fbd2cfb270..3d6e2328b6 100644 --- a/src/shared/models/country/country.service.ts +++ b/src/shared/models/country/country.service.ts @@ -13,9 +13,7 @@ export class CountryService { // Sort by `displayOrder` ascending, with `name` as the tiebreaker. // Clients (e.g. the realunit-app country picker) consume this order // verbatim instead of hardcoded priority lists. - return [...countries].sort( - (a, b) => a.displayOrder - b.displayOrder || a.name.localeCompare(b.name), - ); + return [...countries].sort((a, b) => a.displayOrder - b.displayOrder || a.name.localeCompare(b.name)); } async getCountry(id: number): Promise { From e0b79ef81afb39c0a78b01a34a0313ddd603e5e5 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 22 May 2026 15:36:25 +0200 Subject: [PATCH 3/3] fix(reference-data): make legal-document/company-info PostgreSQL-compatible Rewrite the migration from MSSQL to PostgreSQL syntax (SERIAL, TIMESTAMP, boolean, hashed constraint names, $-parameters) and switch the partial-index predicates to "enabled" = true. Make CompanyInfoService.getForBrand case-insensitive (ILike) so the lookup stays consistent with the lowercased cache key on a case-sensitive database. --- ...70800171-AddLegalDocumentAndCompanyInfo.js | 21 ++++++++----------- .../company-info/company-info.entity.ts | 2 +- .../company-info/company-info.service.spec.ts | 3 ++- .../company-info/company-info.service.ts | 3 ++- .../legal-document/legal-document.entity.ts | 2 +- .../legal-document/legal-document.service.ts | 6 +++--- 6 files changed, 18 insertions(+), 19 deletions(-) diff --git a/migration/1779370800171-AddLegalDocumentAndCompanyInfo.js b/migration/1779370800171-AddLegalDocumentAndCompanyInfo.js index 16e7c3f3ff..a2562c0df5 100644 --- a/migration/1779370800171-AddLegalDocumentAndCompanyInfo.js +++ b/migration/1779370800171-AddLegalDocumentAndCompanyInfo.js @@ -29,24 +29,22 @@ module.exports = class AddLegalDocumentAndCompanyInfo1779370800171 { async up(queryRunner) { // --- legal_document table --- await queryRunner.query( - `CREATE TABLE "legal_document" ("id" int NOT NULL IDENTITY(1,1), "updated" datetime2 NOT NULL CONSTRAINT "DF_legal_document_updated" DEFAULT getdate(), "created" datetime2 NOT NULL CONSTRAINT "DF_legal_document_created" DEFAULT getdate(), "type" nvarchar(64) NOT NULL, "language" nvarchar(8), "version" nvarchar(32) NOT NULL, "url" nvarchar(1024) NOT NULL, "enabled" bit NOT NULL CONSTRAINT "DF_legal_document_enabled" DEFAULT 1, CONSTRAINT "PK_legal_document" PRIMARY KEY ("id"))`, + `CREATE TABLE "legal_document" ("id" SERIAL NOT NULL, "updated" TIMESTAMP NOT NULL DEFAULT now(), "created" TIMESTAMP NOT NULL DEFAULT now(), "type" character varying(64) NOT NULL, "language" character varying(8), "version" character varying(32) NOT NULL, "url" character varying(1024) NOT NULL, "enabled" boolean NOT NULL DEFAULT true, CONSTRAINT "PK_950166ad59d051a80cad8337c76" PRIMARY KEY ("id"))`, ); await queryRunner.query( - `CREATE UNIQUE INDEX "IDX_legal_document_type_language" ON "legal_document" ("type", "language") WHERE "enabled" = 1`, + `CREATE UNIQUE INDEX "IDX_c3408d5be699dbdc15f529c2ce" ON "legal_document" ("type", "language") WHERE "enabled" = true`, ); // --- company_info table --- await queryRunner.query( - `CREATE TABLE "company_info" ("id" int NOT NULL IDENTITY(1,1), "updated" datetime2 NOT NULL CONSTRAINT "DF_company_info_updated" DEFAULT getdate(), "created" datetime2 NOT NULL CONSTRAINT "DF_company_info_created" DEFAULT getdate(), "brand" nvarchar(64) NOT NULL, "name" nvarchar(256) NOT NULL, "phone" nvarchar(64), "email" nvarchar(256), "website" nvarchar(256), "addressStreet" nvarchar(256), "addressZip" nvarchar(64), "addressCity" nvarchar(256), "addressCountry" nvarchar(8), "enabled" bit NOT NULL CONSTRAINT "DF_company_info_enabled" DEFAULT 1, CONSTRAINT "PK_company_info" PRIMARY KEY ("id"))`, + `CREATE TABLE "company_info" ("id" SERIAL NOT NULL, "updated" TIMESTAMP NOT NULL DEFAULT now(), "created" TIMESTAMP NOT NULL DEFAULT now(), "brand" character varying(64) NOT NULL, "name" character varying(256) NOT NULL, "phone" character varying(64), "email" character varying(256), "website" character varying(256), "addressStreet" character varying(256), "addressZip" character varying(64), "addressCity" character varying(256), "addressCountry" character varying(8), "enabled" boolean NOT NULL DEFAULT true, CONSTRAINT "PK_88c3e323679d0747ffbb83f3f78" PRIMARY KEY ("id"))`, ); await queryRunner.query( - `CREATE UNIQUE INDEX "IDX_company_info_brand" ON "company_info" ("brand") WHERE "enabled" = 1`, + `CREATE UNIQUE INDEX "IDX_9a97b98884dfa6dfb6c8cc7b11" ON "company_info" ("brand") WHERE "enabled" = true`, ); // --- country.displayOrder column --- - await queryRunner.query( - `ALTER TABLE "country" ADD "displayOrder" int NOT NULL CONSTRAINT "DF_country_displayOrder" DEFAULT 999`, - ); + await queryRunner.query(`ALTER TABLE "country" ADD "displayOrder" integer NOT NULL DEFAULT 999`); // Seed the priority order the realunit-app used to hardcode: // const priorityCountries = ['CH', 'DE', 'IT', 'FR'] await queryRunner.query(`UPDATE "country" SET "displayOrder" = 1 WHERE "symbol" = 'CH'`); @@ -67,14 +65,14 @@ module.exports = class AddLegalDocumentAndCompanyInfo1779370800171 { ]; for (const doc of legalDocs) { await queryRunner.query( - `INSERT INTO "legal_document" ("type", "language", "version", "url", "enabled") VALUES (@0, @1, @2, @3, 1)`, + `INSERT INTO "legal_document" ("type", "language", "version", "url", "enabled") VALUES ($1, $2, $3, $4, true)`, [doc.type, doc.language, doc.version, doc.url], ); } // --- seed company_info with the contact info the realunit-app used to ship hardcoded --- await queryRunner.query( - `INSERT INTO "company_info" ("brand", "name", "phone", "email", "website", "addressStreet", "addressZip", "addressCity", "addressCountry", "enabled") VALUES (@0, @1, @2, @3, @4, @5, @6, @7, @8, 1)`, + `INSERT INTO "company_info" ("brand", "name", "phone", "email", "website", "addressStreet", "addressZip", "addressCity", "addressCountry", "enabled") VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, true)`, [ 'RealUnit', 'RealUnit Schweiz AG', @@ -93,13 +91,12 @@ module.exports = class AddLegalDocumentAndCompanyInfo1779370800171 { * @param {QueryRunner} queryRunner */ async down(queryRunner) { - await queryRunner.query(`ALTER TABLE "country" DROP CONSTRAINT "DF_country_displayOrder"`); await queryRunner.query(`ALTER TABLE "country" DROP COLUMN "displayOrder"`); - await queryRunner.query(`DROP INDEX "IDX_company_info_brand" ON "company_info"`); + await queryRunner.query(`DROP INDEX "public"."IDX_9a97b98884dfa6dfb6c8cc7b11"`); await queryRunner.query(`DROP TABLE "company_info"`); - await queryRunner.query(`DROP INDEX "IDX_legal_document_type_language" ON "legal_document"`); + await queryRunner.query(`DROP INDEX "public"."IDX_c3408d5be699dbdc15f529c2ce"`); await queryRunner.query(`DROP TABLE "legal_document"`); } }; diff --git a/src/shared/models/company-info/company-info.entity.ts b/src/shared/models/company-info/company-info.entity.ts index 91e148402a..7a4b121105 100644 --- a/src/shared/models/company-info/company-info.entity.ts +++ b/src/shared/models/company-info/company-info.entity.ts @@ -2,7 +2,7 @@ import { Column, Entity, Index } from 'typeorm'; import { IEntity } from '../entity'; @Entity() -@Index(['brand'], { unique: true, where: '"enabled" = 1' }) +@Index(['brand'], { unique: true, where: '"enabled" = true' }) export class CompanyInfo extends IEntity { // Brand identifier — `RealUnit`, `DFX`, etc. Lets a single backend serve // multiple branded apps from one endpoint. diff --git a/src/shared/models/company-info/company-info.service.spec.ts b/src/shared/models/company-info/company-info.service.spec.ts index fe167d9c89..9b329bba33 100644 --- a/src/shared/models/company-info/company-info.service.spec.ts +++ b/src/shared/models/company-info/company-info.service.spec.ts @@ -1,6 +1,7 @@ import { createMock } from '@golevelup/ts-jest'; import { NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; +import { ILike } from 'typeorm'; import { CompanyInfo } from './company-info.entity'; import { CompanyInfoRepository } from './company-info.repository'; import { CompanyInfoService } from './company-info.service'; @@ -41,7 +42,7 @@ describe('CompanyInfoService', () => { expect(result).toBe(info); expect(repo.findOneCachedBy).toHaveBeenCalledWith('brand:realunit', { - brand: 'RealUnit', + brand: ILike('RealUnit'), enabled: true, }); }); diff --git a/src/shared/models/company-info/company-info.service.ts b/src/shared/models/company-info/company-info.service.ts index 3d66a0dda2..4818abc918 100644 --- a/src/shared/models/company-info/company-info.service.ts +++ b/src/shared/models/company-info/company-info.service.ts @@ -1,4 +1,5 @@ import { Injectable, NotFoundException } from '@nestjs/common'; +import { ILike } from 'typeorm'; import { CompanyInfo } from './company-info.entity'; import { CompanyInfoRepository } from './company-info.repository'; @@ -8,7 +9,7 @@ export class CompanyInfoService { async getForBrand(brand: string): Promise { const info = await this.repo.findOneCachedBy(`brand:${brand.toLowerCase()}`, { - brand, + brand: ILike(brand), enabled: true, }); if (!info) throw new NotFoundException(`CompanyInfo for brand "${brand}" not found`); diff --git a/src/shared/models/legal-document/legal-document.entity.ts b/src/shared/models/legal-document/legal-document.entity.ts index e6b5d1fcee..381e518114 100644 --- a/src/shared/models/legal-document/legal-document.entity.ts +++ b/src/shared/models/legal-document/legal-document.entity.ts @@ -11,7 +11,7 @@ export enum LegalDocumentType { } @Entity() -@Index(['type', 'language'], { unique: true, where: '"enabled" = 1' }) +@Index(['type', 'language'], { unique: true, where: '"enabled" = true' }) export class LegalDocument extends IEntity { @Column({ length: 64 }) type: LegalDocumentType; diff --git a/src/shared/models/legal-document/legal-document.service.ts b/src/shared/models/legal-document/legal-document.service.ts index db45ca8320..8d4259f8a0 100644 --- a/src/shared/models/legal-document/legal-document.service.ts +++ b/src/shared/models/legal-document/legal-document.service.ts @@ -6,9 +6,9 @@ import { LegalDocumentRepository } from './legal-document.repository'; export class LegalDocumentService { constructor(private readonly repo: LegalDocumentRepository) {} - /// Returns every enabled document, optionally filtered by type / language. - /// The cache key encodes both so repeated identical queries share the - /// cached result. + // Returns every enabled document, optionally filtered by type / language. + // The cache key encodes both so repeated identical queries share the + // cached result. async getDocuments(filters: { type?: LegalDocumentType; language?: string } = {}): Promise { const cacheKey = `enabled:${filters.type ?? '*'}:${filters.language ?? '*'}`; const where: Partial = { enabled: true };