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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions migration/1781031292273-AddPartnerConsent.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* @typedef {import('typeorm').MigrationInterface} MigrationInterface
* @typedef {import('typeorm').QueryRunner} QueryRunner
*/

/**
* @class
* @implements {MigrationInterface}
*/
module.exports = class AddPartnerConsent1781031292273 {
name = 'AddPartnerConsent1781031292273'

/**
* @param {QueryRunner} queryRunner
*/
async up(queryRunner) {
await queryRunner.query(`CREATE TABLE "partner_consent" ("id" SERIAL NOT NULL, "updated" TIMESTAMP NOT NULL DEFAULT now(), "created" TIMESTAMP NOT NULL DEFAULT now(), "topic" character varying(256) NOT NULL, "version" integer NOT NULL, "partnerId" integer NOT NULL, "userDataId" integer NOT NULL, CONSTRAINT "PK_67707b8dfd2fe11b9709673d326" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE INDEX "IDX_d7d7bafbd3629778eb1376d1ab" ON "partner_consent" ("partnerId") `);
await queryRunner.query(`CREATE INDEX "IDX_f6629dbad0f2d3a7cab345f229" ON "partner_consent" ("userDataId") `);
await queryRunner.query(`ALTER TABLE "partner_consent" ADD CONSTRAINT "FK_d7d7bafbd3629778eb1376d1ab3" FOREIGN KEY ("partnerId") REFERENCES "wallet"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "partner_consent" ADD CONSTRAINT "FK_f6629dbad0f2d3a7cab345f2294" FOREIGN KEY ("userDataId") REFERENCES "user_data"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
}

/**
* @param {QueryRunner} queryRunner
*/
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "partner_consent" DROP CONSTRAINT "FK_f6629dbad0f2d3a7cab345f2294"`);
await queryRunner.query(`ALTER TABLE "partner_consent" DROP CONSTRAINT "FK_d7d7bafbd3629778eb1376d1ab3"`);
await queryRunner.query(`DROP INDEX "public"."IDX_f6629dbad0f2d3a7cab345f229"`);
await queryRunner.query(`DROP INDEX "public"."IDX_d7d7bafbd3629778eb1376d1ab"`);
await queryRunner.query(`DROP TABLE "partner_consent"`);
}
}
14 changes: 14 additions & 0 deletions src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { KycIdentificationType } from 'src/subdomains/generic/user/models/user-d
import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity';
import { LegalEntity } from 'src/subdomains/generic/user/models/user-data/user-data.enum';
import { MailOptions } from 'src/subdomains/supporting/notification/services/mail.service';
import { RealUnitDisclaimerTopic } from 'src/subdomains/supporting/realunit/enums/realunit-disclaimer-topic.enum';
import { LoggerOptions } from 'typeorm';
import { EVM_CHAINS } from './chains.config';

Expand Down Expand Up @@ -1074,6 +1075,19 @@ export class Configuration {
city: process.env.REALUNIT_ADDRESS_CITY ?? 'Baar',
country: process.env.REALUNIT_ADDRESS_COUNTRY ?? 'Switzerland',
},
// Current required version per legal-disclaimer step. Bump a value when the
// corresponding content (i18n text or linked documents in the app) changes
// — the user is then re-prompted for that step only. Keep in sync with the
// app deployment that ships the new content.
disclaimer: {
versions: {
[RealUnitDisclaimerTopic.DISCLAIMER_PART_1]: 1,
[RealUnitDisclaimerTopic.DISCLAIMER_PART_2]: 1,
[RealUnitDisclaimerTopic.REALUNIT_DOCUMENTS]: 1,
[RealUnitDisclaimerTopic.AKTIONARIAT_DOCUMENTS]: 1,
[RealUnitDisclaimerTopic.DFX_DOCUMENTS]: 1,
} as Record<RealUnitDisclaimerTopic, number>,
},
},
ebel2x: {
contractAddress: process.env.EBEL2X_CONTRACT_ADDRESS,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { Test, TestingModule } from '@nestjs/testing';
import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity';
import { Wallet } from 'src/subdomains/generic/user/models/wallet/wallet.entity';
import { PartnerConsentRepository } from '../partner-consent.repository';
import { PartnerConsentService } from '../partner-consent.service';

describe('PartnerConsentService', () => {
let service: PartnerConsentService;
let repo: jest.Mocked<PartnerConsentRepository>;
let queryBuilder: {
select: jest.Mock;
addSelect: jest.Mock;
where: jest.Mock;
andWhere: jest.Mock;
groupBy: jest.Mock;
getRawMany: jest.Mock;
};

const partner = Object.assign(new Wallet(), { id: 5 });
const userData = Object.assign(new UserData(), { id: 10 });

beforeEach(async () => {
queryBuilder = {
select: jest.fn().mockReturnThis(),
addSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
groupBy: jest.fn().mockReturnThis(),
getRawMany: jest.fn(),
};

const module: TestingModule = await Test.createTestingModule({
providers: [
PartnerConsentService,
{
provide: PartnerConsentRepository,
useValue: {
createQueryBuilder: jest.fn(() => queryBuilder),
create: jest.fn((x) => x),
save: jest.fn(),
},
},
],
}).compile();

service = module.get(PartnerConsentService);
repo = module.get(PartnerConsentRepository);
});

afterEach(() => jest.clearAllMocks());

describe('getConfirmedVersions', () => {
it('maps the highest version per topic and filters by user and partner id', async () => {
queryBuilder.getRawMany.mockResolvedValue([
{ topic: 'A', version: 2 },
{ topic: 'B', version: 1 },
]);

const result = await service.getConfirmedVersions(userData, partner);

expect(result).toEqual(
new Map([
['A', 2],
['B', 1],
]),
);
expect(queryBuilder.where).toHaveBeenCalledWith('consent.userDataId = :userDataId', { userDataId: 10 });
expect(queryBuilder.andWhere).toHaveBeenCalledWith('consent.partnerId = :partnerId', { partnerId: 5 });
expect(queryBuilder.groupBy).toHaveBeenCalledWith('consent.topic');
});
});

describe('getMissingTopics', () => {
it('returns topics whose required version exceeds the confirmed version', async () => {
queryBuilder.getRawMany.mockResolvedValue([
{ topic: 'A', version: 2 },
{ topic: 'B', version: 1 },
]);

const required = new Map([
['A', 2],
['B', 2],
['C', 1],
]);
const result = await service.getMissingTopics(userData, partner, required);

// A: confirmed 2 >= required 2 -> ok; B: 1 < 2 -> missing; C: nothing < 1 -> missing
expect(result).toEqual(['B', 'C']);
});

it('treats every required topic as missing when nothing is confirmed', async () => {
queryBuilder.getRawMany.mockResolvedValue([]);

const required = new Map([
['A', 1],
['B', 1],
]);
const result = await service.getMissingTopics(userData, partner, required);

expect(result).toEqual(['A', 'B']);
});
});

describe('confirm', () => {
it('appends one row per entry referencing the user and partner id', async () => {
await service.confirm(userData, partner, [
{ topic: 'A', version: 1 },
{ topic: 'B', version: 3 },
]);

expect(repo.create).toHaveBeenCalledTimes(2);
expect(repo.save).toHaveBeenCalledWith([
{ userData: { id: 10 }, partner: { id: 5 }, topic: 'A', version: 1 },
{ userData: { id: 10 }, partner: { id: 5 }, topic: 'B', version: 3 },
]);
});

it('does nothing for an empty entry list', async () => {
await service.confirm(userData, partner, []);

expect(repo.create).not.toHaveBeenCalled();
expect(repo.save).not.toHaveBeenCalled();
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { IEntity } from 'src/shared/models/entity';
import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity';
import { Wallet } from 'src/subdomains/generic/user/models/wallet/wallet.entity';
import { Column, Entity, Index, ManyToOne } from 'typeorm';

// Append-only record of a user accepting a versioned consent topic for a given
// partner (the DFX-side "partner" is the existing Wallet entity — see
// docs/partner-consent). The latest accepted version of a topic is the highest
// `version` for a (userData, partner, topic) tuple; older rows are kept as the
// legal audit trail of when which version was accepted.
@Entity()
export class PartnerConsent extends IEntity {
@Index()
@ManyToOne(() => Wallet, { nullable: false })
partner: Wallet;

@Index()
@ManyToOne(() => UserData, { nullable: false })
userData: UserData;

@Column({ length: 256 })
topic: string;

@Column({ type: 'int' })
version: number;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PartnerConsent } from './partner-consent.entity';
import { PartnerConsentRepository } from './partner-consent.repository';
import { PartnerConsentService } from './partner-consent.service';

@Module({
imports: [TypeOrmModule.forFeature([PartnerConsent])],
providers: [PartnerConsentRepository, PartnerConsentService],
exports: [PartnerConsentService],
})
export class PartnerConsentModule {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Injectable } from '@nestjs/common';
import { BaseRepository } from 'src/shared/repositories/base.repository';
import { EntityManager } from 'typeorm';
import { PartnerConsent } from './partner-consent.entity';

@Injectable()
export class PartnerConsentRepository extends BaseRepository<PartnerConsent> {
constructor(manager: EntityManager) {
super(PartnerConsent, manager);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { Injectable } from '@nestjs/common';
import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity';
import { Wallet } from 'src/subdomains/generic/user/models/wallet/wallet.entity';
import { PartnerConsentRepository } from './partner-consent.repository';

export interface PartnerConsentEntry {
topic: string;
version: number;
}

@Injectable()
export class PartnerConsentService {
constructor(private readonly repo: PartnerConsentRepository) {}

// Highest accepted version per topic for the (userData, partner) pair.
async getConfirmedVersions(userData: UserData, partner: Wallet): Promise<Map<string, number>> {
const rows = await this.repo
.createQueryBuilder('consent')
.select('consent.topic', 'topic')
.addSelect('MAX(consent.version)', 'version')
.where('consent.userDataId = :userDataId', { userDataId: userData.id })
.andWhere('consent.partnerId = :partnerId', { partnerId: partner.id })
.groupBy('consent.topic')
.getRawMany<{ topic: string; version: number }>();

return new Map(rows.map((r) => [r.topic, Number(r.version)]));
}

// Topics whose required version the user has not accepted yet. The caller owns
// the source of truth for required versions (e.g. partner config); this service
// only compares it against what is stored.
async getMissingTopics(userData: UserData, partner: Wallet, required: Map<string, number>): Promise<string[]> {
const confirmed = await this.getConfirmedVersions(userData, partner);
return [...required.entries()]
.filter(([topic, version]) => (confirmed.get(topic) ?? 0) < version)
.map(([topic]) => topic);
}

// Append-only: one new row per confirmed topic, stamped with the version the
// caller passes in (the partner-side current version).
async confirm(userData: UserData, partner: Wallet, entries: PartnerConsentEntry[]): Promise<void> {
if (!entries.length) return;

const consents = entries.map((e) =>
this.repo.create({
userData: { id: userData.id },
partner: { id: partner.id },
topic: e.topic,
version: e.version,
}),
);
await this.repo.save(consents);
}
}
Loading