diff --git a/migration/1779370800171-AddLegalDocumentAndCompanyInfo.js b/migration/1779370800171-AddLegalDocumentAndCompanyInfo.js new file mode 100644 index 0000000000..a2562c0df5 --- /dev/null +++ b/migration/1779370800171-AddLegalDocumentAndCompanyInfo.js @@ -0,0 +1,102 @@ +/** + * @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" 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_c3408d5be699dbdc15f529c2ce" ON "legal_document" ("type", "language") WHERE "enabled" = true`, + ); + + // --- company_info table --- + await queryRunner.query( + `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_9a97b98884dfa6dfb6c8cc7b11" ON "company_info" ("brand") WHERE "enabled" = true`, + ); + + // --- country.displayOrder column --- + 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'`); + 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 ($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 ($1, $2, $3, $4, $5, $6, $7, $8, $9, true)`, + [ + '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 COLUMN "displayOrder"`); + + await queryRunner.query(`DROP INDEX "public"."IDX_9a97b98884dfa6dfb6c8cc7b11"`); + await queryRunner.query(`DROP TABLE "company_info"`); + + 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.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..7a4b121105 --- /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" = true' }) +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..9b329bba33 --- /dev/null +++ b/src/shared/models/company-info/company-info.service.spec.ts @@ -0,0 +1,55 @@ +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'; + +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: ILike('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..4818abc918 --- /dev/null +++ b/src/shared/models/company-info/company-info.service.ts @@ -0,0 +1,18 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { ILike } from 'typeorm'; +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: ILike(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..65339553eb --- /dev/null +++ b/src/shared/models/company-info/dto/company-info-dto.mapper.ts @@ -0,0 +1,28 @@ +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..3d6e2328b6 100644 --- a/src/shared/models/country/country.service.ts +++ b/src/shared/models/country/country.service.ts @@ -9,7 +9,11 @@ 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..381e518114 --- /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" = true' }) +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..8d4259f8a0 --- /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,