@stamhoofd/backend 2.132.0 → 2.133.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +17 -17
- package/src/endpoints/admin/organizations/ChargeOrganizationsEndpoint.ts +1 -1
- package/src/endpoints/global/groups/GetGroupsEndpoint.ts +1 -1
- package/src/endpoints/organization/dashboard/invoices/PatchInvoicesEndpoint.test.ts +143 -0
- package/src/endpoints/organization/dashboard/invoices/PatchInvoicesEndpoint.ts +10 -1
- package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.test.ts +189 -1
- package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +26 -0
- package/src/endpoints/organization/dashboard/receivable-balances/ChargeReceivableBalancesEndpoint.ts +12 -5
- package/src/helpers/StripeInvoicer.ts +0 -4
- package/src/helpers/StripePayoutChecker.ts +0 -4
- package/src/helpers/ViesHelper.test.ts +145 -1
- package/src/helpers/ViesHelper.ts +39 -0
- package/src/seeds/1783520259-make-old-group-filters-readable.ts +454 -0
- package/src/services/InvoiceService.ts +21 -0
- package/src/services/InvoiceXMLService.test.ts +161 -0
- package/src/services/InvoiceXMLService.ts +11 -9
- package/src/services/PeppolDirectoryService.test.ts +139 -0
- package/src/services/PeppolDirectoryService.ts +111 -0
- package/src/services/uitpas/searchUitpasEvents.ts +3 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Address, Company } from '@stamhoofd/structures';
|
|
1
|
+
import { Address, Company, PeppolEndointId } from '@stamhoofd/structures';
|
|
2
2
|
import { STExpect, TestUtils } from '@stamhoofd/test-utils';
|
|
3
3
|
import { Country } from '@stamhoofd/types/Country';
|
|
4
4
|
import nock from 'nock';
|
|
@@ -7,6 +7,9 @@ import { ViesHelper } from './ViesHelper.js';
|
|
|
7
7
|
const VIES_HOST = 'https://ec.europa.eu';
|
|
8
8
|
const VIES_PATH = '/taxation_customs/vies/rest-api/check-vat-number';
|
|
9
9
|
|
|
10
|
+
const DIRECTORY_HOST = 'https://directory.peppol.eu';
|
|
11
|
+
const DIRECTORY_PATH = '/search/1.0/json';
|
|
12
|
+
|
|
10
13
|
describe('ViesHelper', () => {
|
|
11
14
|
/**
|
|
12
15
|
* Registers a mock for the (only) external dependency: the VIES check-vat-number endpoint.
|
|
@@ -253,5 +256,146 @@ describe('ViesHelper', () => {
|
|
|
253
256
|
expect(patch.VATNumber).toBe('BE0411905847');
|
|
254
257
|
expect(patch.companyNumber).toBe('0411905847');
|
|
255
258
|
});
|
|
259
|
+
|
|
260
|
+
describe('custom PEPPOL endpoint id', () => {
|
|
261
|
+
function mockDirectoryFound(participant: string, entityName = 'Directory Name') {
|
|
262
|
+
return nock(DIRECTORY_HOST)
|
|
263
|
+
.get(DIRECTORY_PATH)
|
|
264
|
+
.query({ participant })
|
|
265
|
+
.reply(200, {
|
|
266
|
+
'total-result-count': 1,
|
|
267
|
+
matches: [{
|
|
268
|
+
participantID: { scheme: 'iso6523-actorid-upis', value: participant.split('::')[1] },
|
|
269
|
+
entities: [{ name: [{ name: entityName }] }],
|
|
270
|
+
}],
|
|
271
|
+
} as nock.Body);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function mockDirectoryNotFound(participant: string) {
|
|
275
|
+
return nock(DIRECTORY_HOST)
|
|
276
|
+
.get(DIRECTORY_PATH)
|
|
277
|
+
.query({ participant })
|
|
278
|
+
.reply(200, { 'total-result-count': 0, matches: [] } as nock.Body);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Use a GLN endpoint id for the generic directory/entityName tests: it has no
|
|
282
|
+
// extra constraints, unlike a KBO (0208) id which must match the company number.
|
|
283
|
+
const companyWithPeppol = () => Company.create({
|
|
284
|
+
name: 'Demo Company',
|
|
285
|
+
address: belgianAddress(),
|
|
286
|
+
customPeppolEndpointId: PeppolEndointId.create({ schemeID: '0088', id: '5412345000013' }),
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test('validates the id against the directory when it was changed', async () => {
|
|
290
|
+
const scope = mockDirectoryFound('iso6523-actorid-upis::0088:5412345000013');
|
|
291
|
+
|
|
292
|
+
const company = companyWithPeppol();
|
|
293
|
+
const patch = Company.patch({ customPeppolEndpointId: PeppolEndointId.create({ schemeID: '0088', id: '5412345000013' }) });
|
|
294
|
+
|
|
295
|
+
await expect(ViesHelper.checkCompany(company, patch)).resolves.toBeUndefined();
|
|
296
|
+
expect(scope.isDone()).toBe(true);
|
|
297
|
+
// The registered entity name is stored from the directory.
|
|
298
|
+
expect(company.customPeppolEndpointId?.entityName).toBe('Directory Name');
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test('overwrites a client-supplied entityName with the value from the directory', async () => {
|
|
302
|
+
mockDirectoryFound('iso6523-actorid-upis::0088:5412345000013');
|
|
303
|
+
|
|
304
|
+
const company = Company.create({
|
|
305
|
+
name: 'Demo Company',
|
|
306
|
+
address: belgianAddress(),
|
|
307
|
+
customPeppolEndpointId: PeppolEndointId.create({ schemeID: '0088', id: '5412345000013', entityName: 'Client supplied name' }),
|
|
308
|
+
});
|
|
309
|
+
const patch = Company.patch({ customPeppolEndpointId: PeppolEndointId.create({ schemeID: '0088', id: '5412345000013', entityName: 'Client supplied name' }) });
|
|
310
|
+
|
|
311
|
+
await ViesHelper.checkCompany(company, patch);
|
|
312
|
+
|
|
313
|
+
// entityName is server-controlled: the client value is discarded.
|
|
314
|
+
expect(company.customPeppolEndpointId?.entityName).toBe('Directory Name');
|
|
315
|
+
expect(patch.customPeppolEndpointId).not.toBeNull();
|
|
316
|
+
expect((patch.customPeppolEndpointId as PeppolEndointId | undefined)?.entityName).toBe('Directory Name');
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test('does not contact the directory when the id did not change', async () => {
|
|
320
|
+
const scope = mockDirectoryFound('iso6523-actorid-upis::0088:5412345000013');
|
|
321
|
+
|
|
322
|
+
const company = companyWithPeppol();
|
|
323
|
+
const patch = Company.patch({ name: 'Renamed Company' });
|
|
324
|
+
|
|
325
|
+
await ViesHelper.checkCompany(company, patch);
|
|
326
|
+
expect(scope.isDone()).toBe(false);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test('rejects an id that is unknown to the directory', async () => {
|
|
330
|
+
mockDirectoryNotFound('iso6523-actorid-upis::0088:5412345000013');
|
|
331
|
+
|
|
332
|
+
const company = companyWithPeppol();
|
|
333
|
+
const patch = Company.patch({ customPeppolEndpointId: PeppolEndointId.create({ schemeID: '0088', id: '5412345000013' }) });
|
|
334
|
+
|
|
335
|
+
await expect(ViesHelper.checkCompany(company, patch)).rejects.toThrow(
|
|
336
|
+
STExpect.simpleError({ code: 'invalid_field', field: 'customPeppolEndpointId' }),
|
|
337
|
+
);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test('does not contact the directory when the id is cleared', async () => {
|
|
341
|
+
const scope = mockDirectoryFound('iso6523-actorid-upis::0088:5412345000013');
|
|
342
|
+
|
|
343
|
+
const company = Company.create({ name: 'Demo Company', address: belgianAddress() });
|
|
344
|
+
const patch = Company.patch({ customPeppolEndpointId: null });
|
|
345
|
+
|
|
346
|
+
await ViesHelper.checkCompany(company, patch);
|
|
347
|
+
expect(scope.isDone()).toBe(false);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test('rejects a KBO endpoint id for a non-Belgian company before contacting the directory', async () => {
|
|
351
|
+
const scope = mockDirectoryFound('iso6523-actorid-upis::0208:0411905847');
|
|
352
|
+
|
|
353
|
+
const company = Company.create({
|
|
354
|
+
name: 'Demo Company',
|
|
355
|
+
address: Address.create({ street: 'Damstraat', number: '1', city: 'Amsterdam', postalCode: '1012', country: Country.Netherlands }),
|
|
356
|
+
customPeppolEndpointId: PeppolEndointId.create({ schemeID: '0208', id: '0411905847' }),
|
|
357
|
+
});
|
|
358
|
+
const patch = Company.patch({ customPeppolEndpointId: PeppolEndointId.create({ schemeID: '0208', id: '0411905847' }) });
|
|
359
|
+
|
|
360
|
+
await expect(ViesHelper.checkCompany(company, patch)).rejects.toThrow(
|
|
361
|
+
STExpect.simpleError({ code: 'invalid_field', field: 'customPeppolEndpointId' }),
|
|
362
|
+
);
|
|
363
|
+
expect(scope.isDone()).toBe(false);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test('rejects a KBO endpoint id that does not match the company number before contacting the directory', async () => {
|
|
367
|
+
const scope = mockDirectoryFound('iso6523-actorid-upis::0208:9999999999');
|
|
368
|
+
|
|
369
|
+
const company = Company.create({
|
|
370
|
+
name: 'Demo Company',
|
|
371
|
+
address: belgianAddress(),
|
|
372
|
+
companyNumber: '0411905847',
|
|
373
|
+
customPeppolEndpointId: PeppolEndointId.create({ schemeID: '0208', id: '9999999999' }),
|
|
374
|
+
});
|
|
375
|
+
const patch = Company.patch({ customPeppolEndpointId: PeppolEndointId.create({ schemeID: '0208', id: '9999999999' }) });
|
|
376
|
+
|
|
377
|
+
await expect(ViesHelper.checkCompany(company, patch)).rejects.toThrow(
|
|
378
|
+
STExpect.simpleError({ code: 'invalid_field', field: 'customPeppolEndpointId' }),
|
|
379
|
+
);
|
|
380
|
+
expect(scope.isDone()).toBe(false);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test('accepts a KBO endpoint id that matches the company number', async () => {
|
|
384
|
+
mockVies({ valid: true });
|
|
385
|
+
const scope = mockDirectoryFound('iso6523-actorid-upis::0208:0411905847');
|
|
386
|
+
|
|
387
|
+
const company = Company.create({
|
|
388
|
+
name: 'Demo Company',
|
|
389
|
+
address: belgianAddress(),
|
|
390
|
+
companyNumber: '0411905847',
|
|
391
|
+
customPeppolEndpointId: PeppolEndointId.create({ schemeID: '0208', id: '0411905847' }),
|
|
392
|
+
});
|
|
393
|
+
const patch = Company.patch({ customPeppolEndpointId: PeppolEndointId.create({ schemeID: '0208', id: '0411905847' }) });
|
|
394
|
+
|
|
395
|
+
await expect(ViesHelper.checkCompany(company, patch)).resolves.toBeUndefined();
|
|
396
|
+
expect(scope.isDone()).toBe(true);
|
|
397
|
+
expect(company.customPeppolEndpointId?.entityName).toBe('Directory Name');
|
|
398
|
+
});
|
|
399
|
+
});
|
|
256
400
|
});
|
|
257
401
|
});
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { AutoEncoderPatchType } from '@simonbackx/simple-encoding';
|
|
2
2
|
import { isSimpleError, isSimpleErrors, SimpleError } from '@simonbackx/simple-errors';
|
|
3
3
|
import type { Company } from '@stamhoofd/structures';
|
|
4
|
+
import { PeppolScheme } from '@stamhoofd/structures';
|
|
4
5
|
import { Country } from '@stamhoofd/types/Country';
|
|
5
6
|
import axios from 'axios';
|
|
6
7
|
import jsvat from 'jsvat-next';
|
|
8
|
+
import { PeppolDirectoryService } from '../services/PeppolDirectoryService.js';
|
|
7
9
|
|
|
8
10
|
export class ViesHelperStatic {
|
|
9
11
|
testMode = false;
|
|
@@ -27,6 +29,43 @@ export class ViesHelperStatic {
|
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
async checkCompany(company: Company, patch: AutoEncoderPatchType<Company> | Company) {
|
|
32
|
+
// Validate the custom PEPPOL endpoint id, but only when it actually changed.
|
|
33
|
+
// In a patch, `customPeppolEndpointId` is undefined unless the client changed it;
|
|
34
|
+
// for a full company it is always set (null or a value), so we validate a set value.
|
|
35
|
+
if (patch.customPeppolEndpointId !== undefined && company.customPeppolEndpointId) {
|
|
36
|
+
const endpointId = company.customPeppolEndpointId;
|
|
37
|
+
|
|
38
|
+
// A KBO (0208) endpoint id is the Belgian enterprise number, so it must belong to a
|
|
39
|
+
// Belgian company and be identical to this company's own company number.
|
|
40
|
+
if (endpointId.schemeID === (PeppolScheme.KBO as string)) {
|
|
41
|
+
if (company.address?.country !== Country.Belgium) {
|
|
42
|
+
throw new SimpleError({
|
|
43
|
+
code: 'invalid_field',
|
|
44
|
+
message: 'A KBO PEPPOL id can only be used for a Belgian company',
|
|
45
|
+
human: $t('%Zck'),
|
|
46
|
+
field: 'customPeppolEndpointId',
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const companyPeppolId = company.peppolCompanyId;
|
|
51
|
+
if (!companyPeppolId || endpointId.id.replace(/\D+/g, '') !== companyPeppolId.id.replace(/\D+/g, '')) {
|
|
52
|
+
throw new SimpleError({
|
|
53
|
+
code: 'invalid_field',
|
|
54
|
+
message: 'A KBO PEPPOL id must match the company number',
|
|
55
|
+
human: $t('%ZcZ'),
|
|
56
|
+
field: 'customPeppolEndpointId',
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// validate() fills in the server-controlled entityName from the PEPPOL directory.
|
|
62
|
+
await PeppolDirectoryService.validate(endpointId);
|
|
63
|
+
|
|
64
|
+
// Write the validated endpoint id back as a full replacement so any client-supplied
|
|
65
|
+
// entityName is discarded: entityName can only ever be set by the server.
|
|
66
|
+
patch.customPeppolEndpointId = endpointId;
|
|
67
|
+
}
|
|
68
|
+
|
|
30
69
|
if (!company.address) {
|
|
31
70
|
// Not allowed to set
|
|
32
71
|
patch.companyNumber = null;
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
import { Migration } from '@simonbackx/simple-database';
|
|
2
|
+
import { Group, Organization, RegistrationPeriod } from '@stamhoofd/models';
|
|
3
|
+
import type { RecordCategory, RegistrationPeriodBase, StamhoofdCompareValue, StamhoofdFilter, StamhoofdMagicRelationFilter } from '@stamhoofd/structures';
|
|
4
|
+
import { GroupType } from '@stamhoofd/structures';
|
|
5
|
+
import { SeedTools } from '../helpers/SeedTools.js';
|
|
6
|
+
|
|
7
|
+
export async function startMigration(dryRun = false) {
|
|
8
|
+
await SeedTools.loop({
|
|
9
|
+
batchSize: 100,
|
|
10
|
+
query: Organization.select(),
|
|
11
|
+
action: async (organization: Organization) => {
|
|
12
|
+
await fixUnreadableGroupFilters(organization, dryRun);
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
if (dryRun) {
|
|
17
|
+
throw new Error('Migration did not finish because of dryRun');
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export default new Migration(async () => {
|
|
22
|
+
if (STAMHOOFD.environment === 'test') {
|
|
23
|
+
console.log('skipped in tests');
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (STAMHOOFD.platformName.toLowerCase() !== 'stamhoofd') {
|
|
28
|
+
console.log('skipped for platform (only runs for Stamhoofd): ' + STAMHOOFD.platformName);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const dryRun = false;
|
|
33
|
+
await startMigration(dryRun);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
function getAllRecordCategories(organization: Organization): RecordCategory[] {
|
|
37
|
+
function getAllRecordCategoiesRecursive(category: RecordCategory) {
|
|
38
|
+
const results: RecordCategory[] = [category];
|
|
39
|
+
|
|
40
|
+
for (const child of category.childCategories) {
|
|
41
|
+
results.push(...getAllRecordCategoiesRecursive(child));
|
|
42
|
+
}
|
|
43
|
+
return results;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return organization.meta.recordsConfiguration.recordCategories.flatMap(c => getAllRecordCategoiesRecursive(c));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function fixUnreadableGroupFilters(organization: Organization, dryRun: boolean) {
|
|
50
|
+
const filtersWithGroupFilters: StamhoofdFilter[] = getAllRecordCategories(organization).flatMap((c) => {
|
|
51
|
+
const filter = c.filter;
|
|
52
|
+
if (filter === null) {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return [filter.enabledWhen, filter.requiredWhen].filter((f) => {
|
|
57
|
+
return hasRegistrationsFilter(f);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
if (filtersWithGroupFilters.length === 0) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
console.log('organization:', organization.name);
|
|
66
|
+
|
|
67
|
+
for (const filter of filtersWithGroupFilters) {
|
|
68
|
+
const groupIds = getGroupIdsFromFilter(filter);
|
|
69
|
+
if (groupIds.length === 0) {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const groups = await Group.getByIDs(...new Set(groupIds));
|
|
74
|
+
const groupMap = new Map(groups.map(g => [g.id, g]));
|
|
75
|
+
|
|
76
|
+
console.error('filter before: ', JSON.stringify(filter));
|
|
77
|
+
|
|
78
|
+
await makeGroupFiltersReadable(filter, groupMap);
|
|
79
|
+
|
|
80
|
+
console.error('filter after: ', JSON.stringify(filter));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!dryRun) {
|
|
84
|
+
await organization.save();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
class StamhoofdFilterHelper {
|
|
89
|
+
static isRecordOrArray(value: StamhoofdFilter): value is { [key: string]: StamhoofdFilter } | StamhoofdFilter[] {
|
|
90
|
+
if (typeof value !== 'object' || value === null || value instanceof Date) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
static isRecord(value: StamhoofdFilter): value is { [key: string]: StamhoofdFilter } {
|
|
98
|
+
if (!this.isRecordOrArray(value) || Array.isArray(value)) {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
static isRecordWithSingleEntry(value: StamhoofdFilter): value is { [key: string]: StamhoofdFilter } {
|
|
106
|
+
if (!this.isRecord(value)) {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return Object.entries(value).length === 1;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
static isEmptyRecord(value: StamhoofdFilter): value is { [key: string]: never } {
|
|
114
|
+
if (!this.isRecord(value)) {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return Object.entries(value).length === 0;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
static hasRegistrationsFilter(filter: StamhoofdFilter): boolean {
|
|
122
|
+
if (filter === null) {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (typeof filter !== 'object') {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (filter instanceof Date) {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (Array.isArray(filter)) {
|
|
135
|
+
return filter.some(f => this.hasRegistrationsFilter(f));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (this.isRegistrationFilter(filter)) {
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// iterate properties
|
|
143
|
+
for (const [, value] of Object.entries(filter as object) as [string, StamhoofdFilter | StamhoofdCompareValue][]) {
|
|
144
|
+
if (this.hasRegistrationsFilter(value)) {
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
static isRegistrationFilter(filter: StamhoofdFilter): filter is { registrations: StamhoofdFilter } & StamhoofdFilter {
|
|
153
|
+
if (!StamhoofdFilterHelper.isRecord(filter)) {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return Object.entries(filter).some(entry => entry[0] === 'registrations');
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function hasRegistrationsFilter(filter: StamhoofdFilter): boolean {
|
|
162
|
+
if (filter === null) {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (typeof filter !== 'object') {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (filter instanceof Date) {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (Array.isArray(filter)) {
|
|
175
|
+
return filter.some(f => hasRegistrationsFilter(f));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (isRegistrationFilter(filter)) {
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// iterate properties
|
|
183
|
+
for (const [, value] of Object.entries(filter as object) as [string, StamhoofdFilter | StamhoofdCompareValue][]) {
|
|
184
|
+
if (hasRegistrationsFilter(value)) {
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function isRegistrationFilter(filter: StamhoofdFilter): filter is { registrations: StamhoofdFilter } & StamhoofdFilter {
|
|
193
|
+
if (!StamhoofdFilterHelper.isRecord(filter)) {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return Object.entries(filter).some(entry => entry[0] === 'registrations');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function getGroupIdsFromRegistrationsFilter(registrationFilter: { registrations: StamhoofdFilter }): string[] {
|
|
201
|
+
if (!StamhoofdFilterHelper.isRecordWithSingleEntry(registrationFilter)) {
|
|
202
|
+
throw new Error('Invalid registration filter: ' + JSON.stringify(registrationFilter));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const entries = Object.entries(registrationFilter);
|
|
206
|
+
|
|
207
|
+
let currentEntry = entries[0];
|
|
208
|
+
|
|
209
|
+
while (true) {
|
|
210
|
+
const currentKey = currentEntry[0];
|
|
211
|
+
|
|
212
|
+
switch (currentKey) {
|
|
213
|
+
case 'registrations':
|
|
214
|
+
case '$elemMatch':
|
|
215
|
+
case '$or':
|
|
216
|
+
case '$and':
|
|
217
|
+
case '$not':
|
|
218
|
+
{
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
default: throw new Error(`Invalid registration filter (currentKey: ${currentKey}): ` + JSON.stringify(registrationFilter));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const currentValue = currentEntry[1];
|
|
225
|
+
|
|
226
|
+
if (StamhoofdFilterHelper.isRecordWithSingleEntry(currentValue)) {
|
|
227
|
+
currentEntry = Object.entries(currentValue)[0];
|
|
228
|
+
} else if (Array.isArray(currentValue)) {
|
|
229
|
+
return currentValue.map(item => getGroupIdsFromGroupFilters(item));
|
|
230
|
+
} else {
|
|
231
|
+
throw new Error('Invalid registration filter: ' + JSON.stringify(registrationFilter));
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function getGroupIdsFromGroupFilters(filter: StamhoofdFilter) {
|
|
237
|
+
const groupId = (filter as { group?: { id?: { $eq?: string } } })?.group?.id?.$eq;
|
|
238
|
+
if (typeof groupId !== 'string') {
|
|
239
|
+
throw new Error('Invalid group filter: ' + JSON.stringify(filter));
|
|
240
|
+
}
|
|
241
|
+
return groupId;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function getGroupIdsFromFilter(filter: StamhoofdFilter): string[] {
|
|
245
|
+
if (!StamhoofdFilterHelper.isRecordOrArray(filter)) {
|
|
246
|
+
throw new Error('Invalid filter: ' + JSON.stringify(filter));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (Array.isArray(filter)) {
|
|
250
|
+
return filter.flatMap(item => getGroupIdsFromFilter(item));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const all: string[] = [];
|
|
254
|
+
|
|
255
|
+
for (const [key, value] of Object.entries(filter)) {
|
|
256
|
+
if (key === 'registrations') {
|
|
257
|
+
all.push(...getGroupIdsFromRegistrationsFilter({ [key]: value }));
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (!StamhoofdFilterHelper.isRecordOrArray(value)) {
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (Array.isArray(value)) {
|
|
265
|
+
if (value.length === 0) {
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const isSomeRecordOrArray = value.some(item => StamhoofdFilterHelper.isRecordOrArray(item));
|
|
270
|
+
|
|
271
|
+
if (!isSomeRecordOrArray) {
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
for (const item of value) {
|
|
276
|
+
// ignore null
|
|
277
|
+
if (item === null) {
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const isRecordOrArray = StamhoofdFilterHelper.isRecordOrArray(item);
|
|
282
|
+
if (!isRecordOrArray) {
|
|
283
|
+
throw new Error('Invalid filter: ' + JSON.stringify(filter));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
all.push(...getGroupIdsFromFilter(item));
|
|
287
|
+
}
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
all.push(...getGroupIdsFromFilter(value));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return all;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function makeGroupFiltersReadable(filter: StamhoofdFilter, groupMap: Map<string, Group>): Promise<void> {
|
|
298
|
+
async function getRelationName(group: Group | null | undefined) {
|
|
299
|
+
if (!group) {
|
|
300
|
+
return 'Verwijderde groep';
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const groupName = group.settings.name.toString();
|
|
304
|
+
|
|
305
|
+
let period: RegistrationPeriodBase | undefined = undefined;
|
|
306
|
+
|
|
307
|
+
// fetch model if no cached period
|
|
308
|
+
const periodModel = (await RegistrationPeriod.getByID(group.periodId));
|
|
309
|
+
if (periodModel) {
|
|
310
|
+
period = periodModel.getBaseStructure();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (period) {
|
|
314
|
+
return `${groupName} (${period.nameShort})`;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return groupName;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function getRelationFilter(filter: StamhoofdFilter, groupMap: Map<string, Group>): Promise<StamhoofdMagicRelationFilter> {
|
|
321
|
+
const groupId = (filter as { group?: { id?: { $eq?: string } } })?.group?.id?.$eq;
|
|
322
|
+
if (typeof groupId !== 'string') {
|
|
323
|
+
throw new Error('Invalid group filter: ' + JSON.stringify(filter));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const group = groupMap.get(groupId);
|
|
327
|
+
if (!group) {
|
|
328
|
+
// double check if group exists, if not: set custom name
|
|
329
|
+
const existingGroup = await Group.getByID(groupId);
|
|
330
|
+
if (existingGroup) {
|
|
331
|
+
// should never happen
|
|
332
|
+
throw new Error(`Group with id ${groupId} exists but is not included in the groupMap`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const relationFilter: StamhoofdMagicRelationFilter = {
|
|
337
|
+
$: '$rel',
|
|
338
|
+
value: groupId,
|
|
339
|
+
type: GroupType.Membership,
|
|
340
|
+
name: await getRelationName(group),
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
return relationFilter;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function makeRegistrationFilterReadable(registrationFilter: { registrations: StamhoofdFilter }): Promise<void> {
|
|
347
|
+
if (!StamhoofdFilterHelper.isRecordWithSingleEntry(registrationFilter)) {
|
|
348
|
+
throw new Error('Invalid registration filter: ' + JSON.stringify(registrationFilter));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const entries = Object.entries(registrationFilter);
|
|
352
|
+
|
|
353
|
+
let parentFilter: StamhoofdFilter | null = null;
|
|
354
|
+
let currentEntry = entries[0];
|
|
355
|
+
const relationFilterArray: StamhoofdMagicRelationFilter[] = [];
|
|
356
|
+
let orParentFilter: StamhoofdFilter | null = null;
|
|
357
|
+
|
|
358
|
+
while (true) {
|
|
359
|
+
const currentKey = currentEntry[0];
|
|
360
|
+
|
|
361
|
+
switch (currentKey) {
|
|
362
|
+
case '$not':
|
|
363
|
+
case '$and': {
|
|
364
|
+
throw new Error(`${currentKey} not supported`);
|
|
365
|
+
}
|
|
366
|
+
case 'registrations':
|
|
367
|
+
case '$elemMatch':
|
|
368
|
+
{
|
|
369
|
+
break;
|
|
370
|
+
}
|
|
371
|
+
case '$or': {
|
|
372
|
+
if (!parentFilter) {
|
|
373
|
+
throw new Error('Not expected parentFilter to be null: ' + JSON.stringify(registrationFilter));
|
|
374
|
+
}
|
|
375
|
+
orParentFilter = parentFilter;
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
default: throw new Error(`Invalid registration filter (currentKey: ${currentKey}): ` + JSON.stringify(registrationFilter));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const currentValue = currentEntry[1];
|
|
382
|
+
|
|
383
|
+
if (StamhoofdFilterHelper.isRecordWithSingleEntry(currentValue)) {
|
|
384
|
+
parentFilter = currentEntry[1];
|
|
385
|
+
currentEntry = Object.entries(currentValue)[0];
|
|
386
|
+
} else if (Array.isArray(currentValue)) {
|
|
387
|
+
if (orParentFilter === null) {
|
|
388
|
+
throw new Error('Not expected orParentFilter to be null');
|
|
389
|
+
}
|
|
390
|
+
for (const item of currentValue) {
|
|
391
|
+
const relationFilter = await getRelationFilter(item, groupMap);
|
|
392
|
+
relationFilterArray.push(relationFilter);
|
|
393
|
+
}
|
|
394
|
+
delete orParentFilter[currentKey];
|
|
395
|
+
(orParentFilter as any)['groupId'] = {
|
|
396
|
+
$in: relationFilterArray,
|
|
397
|
+
};
|
|
398
|
+
return;
|
|
399
|
+
} else {
|
|
400
|
+
throw new Error('Invalid registration filter: ' + JSON.stringify(registrationFilter));
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (!StamhoofdFilterHelper.isRecordOrArray(filter)) {
|
|
406
|
+
throw new Error('Invalid filter: ' + JSON.stringify(filter));
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (Array.isArray(filter)) {
|
|
410
|
+
for (const item of filter) {
|
|
411
|
+
await makeGroupFiltersReadable(item, groupMap);
|
|
412
|
+
}
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
for (const [key, value] of Object.entries(filter)) {
|
|
417
|
+
if (key === 'registrations') {
|
|
418
|
+
await makeRegistrationFilterReadable({ [key]: value });
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
if (!StamhoofdFilterHelper.isRecordOrArray(value)) {
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (Array.isArray(value)) {
|
|
426
|
+
if (value.length === 0) {
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const isSomeRecordOrArray = value.some(item => StamhoofdFilterHelper.isRecordOrArray(item));
|
|
431
|
+
|
|
432
|
+
if (!isSomeRecordOrArray) {
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
for (const item of value) {
|
|
437
|
+
// ignore null
|
|
438
|
+
if (item === null) {
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const isRecordOrArray = StamhoofdFilterHelper.isRecordOrArray(item);
|
|
443
|
+
if (!isRecordOrArray) {
|
|
444
|
+
throw new Error('Invalid filter: ' + JSON.stringify(filter));
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
await makeGroupFiltersReadable(item, groupMap);
|
|
448
|
+
}
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
await makeGroupFiltersReadable(value, groupMap);
|
|
453
|
+
}
|
|
454
|
+
}
|