@stamhoofd/backend 2.129.3 → 2.131.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/crons/index.ts +1 -0
- package/src/crons/stripe-payout-reports.ts +69 -0
- package/src/endpoints/admin/organizations/GetOrganizationsEndpoint.test.ts +324 -2
- package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.test.ts +74 -3
- package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +6 -0
- package/src/endpoints/global/members/throwIfDrasticMemberDetailsChange.test.ts +96 -0
- package/src/endpoints/global/members/throwIfDrasticMemberDetailsChange.ts +58 -0
- package/src/endpoints/global/registration/PatchUserMembersEndpoint.test.ts +68 -0
- package/src/endpoints/global/registration/PatchUserMembersEndpoint.ts +7 -1
- package/src/endpoints/global/registration/RegisterMembersEndpoint.test.ts +44 -3
- package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +31 -2
- package/src/endpoints/organization/dashboard/receivable-balances/ChargeReceivableBalancesEndpoint.ts +1 -1
- package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.test.ts +61 -2
- package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.ts +14 -1
- package/src/endpoints/organization/dashboard/stripe/GetStripePayoutsExportStatusEndpoint.ts +32 -0
- package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.test.ts +103 -0
- package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.ts +125 -0
- package/src/helpers/AuthenticatedStructures.ts +5 -7
- package/src/helpers/MembershipCharger.ts +26 -1
- package/src/helpers/RegistrationPeriodRelationBackfiller.test.ts +111 -0
- package/src/helpers/RegistrationPeriodRelationBackfiller.ts +125 -0
- package/src/helpers/StripePayoutExportData.ts +195 -0
- package/src/helpers/StripePayoutExportExcel.ts +280 -0
- package/src/helpers/StripePayoutReporter.test.ts +419 -0
- package/src/helpers/StripePayoutReporter.ts +585 -0
- package/src/migrations/1783342463-remove-platform-uitpas-flag.ts +25 -0
- package/src/seeds/1783400000-backfill-registration-period-relation.ts +13 -0
- package/src/seeds/1783502528-fix-registrations-with-platform-period.ts +426 -0
- package/src/services/uitpas/UitpasService.ts +1 -1
- package/src/services/uitpas/cancelTicketSales.ts +1 -1
- package/src/services/uitpas/checkPermissionsFor.ts +1 -1
- package/src/services/uitpas/getSocialTariffForEvent.ts +1 -1
- package/src/services/uitpas/getSocialTariffForUitpasNumbers.ts +1 -1
- package/src/services/uitpas/registerTicketSales.ts +1 -1
- package/src/services/uitpas/searchUitpasEvents.ts +22 -18
- package/src/services/uitpas/searchUitpasOrganizers.ts +2 -1
- package/src/sql-filters/organizations.ts +52 -0
- package/tests/e2e/documents.test.ts +3 -1
- package/tests/vitest.global.setup.ts +1 -1
- package/tests/vitest.setup.ts +4 -2
- package/vitest.config.js +10 -0
- /package/src/seeds/{1780665427-schedule-stock-updates.ts → 1783515690-schedule-stock-updates.ts} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stamhoofd/backend",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.131.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"exports": {
|
|
@@ -57,20 +57,20 @@
|
|
|
57
57
|
"@simonbackx/simple-endpoints": "1.21.1",
|
|
58
58
|
"@simonbackx/simple-errors": "1.5.0",
|
|
59
59
|
"@simonbackx/simple-logging": "1.0.1",
|
|
60
|
-
"@stamhoofd/backend-env": "2.
|
|
61
|
-
"@stamhoofd/backend-i18n": "2.
|
|
62
|
-
"@stamhoofd/backend-middleware": "2.
|
|
63
|
-
"@stamhoofd/crons": "2.
|
|
64
|
-
"@stamhoofd/email": "2.
|
|
65
|
-
"@stamhoofd/excel-writer": "2.
|
|
66
|
-
"@stamhoofd/logging": "2.
|
|
67
|
-
"@stamhoofd/models": "2.
|
|
68
|
-
"@stamhoofd/object-differ": "2.
|
|
69
|
-
"@stamhoofd/queues": "2.
|
|
70
|
-
"@stamhoofd/sql": "2.
|
|
71
|
-
"@stamhoofd/structures": "2.
|
|
72
|
-
"@stamhoofd/types": "2.
|
|
73
|
-
"@stamhoofd/utility": "2.
|
|
60
|
+
"@stamhoofd/backend-env": "2.131.0",
|
|
61
|
+
"@stamhoofd/backend-i18n": "2.131.0",
|
|
62
|
+
"@stamhoofd/backend-middleware": "2.131.0",
|
|
63
|
+
"@stamhoofd/crons": "2.131.0",
|
|
64
|
+
"@stamhoofd/email": "2.131.0",
|
|
65
|
+
"@stamhoofd/excel-writer": "2.131.0",
|
|
66
|
+
"@stamhoofd/logging": "2.131.0",
|
|
67
|
+
"@stamhoofd/models": "2.131.0",
|
|
68
|
+
"@stamhoofd/object-differ": "2.131.0",
|
|
69
|
+
"@stamhoofd/queues": "2.131.0",
|
|
70
|
+
"@stamhoofd/sql": "2.131.0",
|
|
71
|
+
"@stamhoofd/structures": "2.131.0",
|
|
72
|
+
"@stamhoofd/types": "2.131.0",
|
|
73
|
+
"@stamhoofd/utility": "2.131.0",
|
|
74
74
|
"archiver": "7.0.1",
|
|
75
75
|
"axios": "1.16.0",
|
|
76
76
|
"base-x": "3.0.11",
|
|
@@ -91,7 +91,7 @@
|
|
|
91
91
|
"stripe": "16.12.0"
|
|
92
92
|
},
|
|
93
93
|
"devDependencies": {
|
|
94
|
-
"@stamhoofd/test-utils": "2.
|
|
94
|
+
"@stamhoofd/test-utils": "2.131.0",
|
|
95
95
|
"@types/cookie": "0.6.0",
|
|
96
96
|
"@types/luxon": "3.7.1",
|
|
97
97
|
"@types/mailparser": "3.4.6",
|
|
@@ -107,5 +107,5 @@
|
|
|
107
107
|
"publishConfig": {
|
|
108
108
|
"access": "public"
|
|
109
109
|
},
|
|
110
|
-
"gitHead": "
|
|
110
|
+
"gitHead": "51de524c831dac65960440d9a5aabe9cad1feae8"
|
|
111
111
|
}
|
package/src/crons/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ import './invoices.js';
|
|
|
11
11
|
import './service-fees.js';
|
|
12
12
|
import './members-fees.js';
|
|
13
13
|
import './stripe-invoices.js';
|
|
14
|
+
import './stripe-payout-reports.js';
|
|
14
15
|
import './transfer-fees.js';
|
|
15
16
|
import './drip-emails.js';
|
|
16
17
|
import './update-organization-future-events.js';
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { registerCron } from '@stamhoofd/crons';
|
|
2
|
+
import { Organization, Platform } from '@stamhoofd/models';
|
|
3
|
+
import { Formatter } from '@stamhoofd/utility';
|
|
4
|
+
import { StripePayoutReporter } from '../helpers/StripePayoutReporter.js';
|
|
5
|
+
|
|
6
|
+
registerCron('stripe-payout-reports', createStripePayoutReports);
|
|
7
|
+
|
|
8
|
+
let lastStripeReport: Date | null = null;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Sends a monthly report of all Stripe payouts of the previous month, so we can check
|
|
12
|
+
* whether everything we charged via Stripe application fees was invoiced correctly.
|
|
13
|
+
*/
|
|
14
|
+
async function createStripePayoutReports() {
|
|
15
|
+
if (STAMHOOFD.environment !== 'production') {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (STAMHOOFD.userMode === 'platform') {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (STAMHOOFD.STRIPE_CONNECT_METHOD === 'standard') {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (new Date().getHours() > 5 || new Date().getHours() < 2) {
|
|
28
|
+
// Fix for sending emails after deployments
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Send the report on the 6th day of the month, once
|
|
33
|
+
const today = new Date();
|
|
34
|
+
if (today.getDate() !== 6 || (lastStripeReport && Formatter.dateWithoutDay(lastStripeReport) === Formatter.dateWithoutDay(today))) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!STAMHOOFD.STRIPE_SECRET_KEY) {
|
|
39
|
+
console.log('No stripe key set');
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const membershipOrganizationId = (await Platform.getShared()).membershipOrganizationId;
|
|
44
|
+
if (!membershipOrganizationId) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const membershipOrganization = await Organization.getByID(membershipOrganizationId, true);
|
|
49
|
+
|
|
50
|
+
console.log('Creating Stripe payout report...');
|
|
51
|
+
|
|
52
|
+
// Previous month
|
|
53
|
+
const previousMonthStart = new Date(today.getFullYear(), today.getMonth() - 1, 1);
|
|
54
|
+
const end = new Date(new Date(today.getFullYear(), today.getMonth(), 1).getTime() - 1000);
|
|
55
|
+
|
|
56
|
+
const reporter = new StripePayoutReporter({
|
|
57
|
+
secretKey: STAMHOOFD.STRIPE_SECRET_KEY,
|
|
58
|
+
sellingOrganization: membershipOrganization,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
await reporter.build(previousMonthStart, end);
|
|
62
|
+
await reporter.sendEmail({
|
|
63
|
+
to: [{ email: 'simon@stamhoofd.be' }],
|
|
64
|
+
// Only send the report to the extra (accounting) recipients when it is complete and valid
|
|
65
|
+
extraRecipientsWhenValid: (STAMHOOFD.STRIPE_PAYOUT_REPORT_EMAILS ?? []).map(email => ({ email })),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
lastStripeReport = new Date();
|
|
69
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Request } from '@simonbackx/simple-endpoints';
|
|
2
2
|
import type { Organization, Token } from '@stamhoofd/models';
|
|
3
|
-
import { OrganizationFactory, STPackageFactory, UserFactory } from '@stamhoofd/models';
|
|
4
|
-
import { LimitedFilteredRequest, OrganizationMetaData, OrganizationType, PermissionLevel, Permissions, STPackageBundle, type StamhoofdFilter, UmbrellaOrganization } from '@stamhoofd/structures';
|
|
3
|
+
import { DocumentTemplateFactory, OrganizationFactory, STPackageFactory, UserFactory } from '@stamhoofd/models';
|
|
4
|
+
import { DocumentStatus, LimitedFilteredRequest, OrganizationMetaData, OrganizationRecordsConfiguration, OrganizationType, PermissionLevel, Permissions, RecordCategory, RecordSettings, RecordType, STPackageBundle, type StamhoofdFilter, TranslatedString, UmbrellaOrganization } from '@stamhoofd/structures';
|
|
5
5
|
import { Country } from '@stamhoofd/types/Country';
|
|
6
6
|
import { TestUtils } from '@stamhoofd/test-utils';
|
|
7
7
|
import { testServer } from '../../../../tests/helpers/TestServer.js';
|
|
@@ -229,4 +229,326 @@ describe('Endpoint.GetOrganizationsEndpoint', () => {
|
|
|
229
229
|
expect(await filter({ createdAt: { $gt: anHourAgo } }, adminToken)).toContain(org.id);
|
|
230
230
|
expect(await filter({ createdAt: { $gt: inAnHour } }, adminToken)).not.toContain(org.id);
|
|
231
231
|
});
|
|
232
|
+
|
|
233
|
+
// Creates an organization with the given record categories (questionnaires) in its records configuration
|
|
234
|
+
const createOrganizationWithRecordCategories = async (name: string, recordCategories: RecordCategory[]) => {
|
|
235
|
+
const meta = OrganizationMetaData.create({
|
|
236
|
+
type: OrganizationType.Other,
|
|
237
|
+
umbrellaOrganization: null,
|
|
238
|
+
defaultEndDate: new Date(),
|
|
239
|
+
defaultStartDate: new Date(),
|
|
240
|
+
defaultPrices: [],
|
|
241
|
+
recordsConfiguration: OrganizationRecordsConfiguration.create({
|
|
242
|
+
recordCategories,
|
|
243
|
+
}),
|
|
244
|
+
});
|
|
245
|
+
return await new OrganizationFactory({ name, meta }).create();
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const createTextRecord = (nameValue: string) => {
|
|
249
|
+
return RecordSettings.create({
|
|
250
|
+
name: TranslatedString.create(nameValue),
|
|
251
|
+
type: RecordType.Text,
|
|
252
|
+
});
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
test('Filters organizations by record category name', async () => {
|
|
256
|
+
const { adminToken } = await initPlatformAdmin();
|
|
257
|
+
|
|
258
|
+
const medicalOrg = await createOrganizationWithRecordCategories('Medical Questionnaire Club', [
|
|
259
|
+
RecordCategory.create({ name: TranslatedString.create('Medische fiche') }),
|
|
260
|
+
]);
|
|
261
|
+
const consentOrg = await createOrganizationWithRecordCategories('Consent Questionnaire Club', [
|
|
262
|
+
RecordCategory.create({ name: TranslatedString.create('Toestemmingen') }),
|
|
263
|
+
]);
|
|
264
|
+
|
|
265
|
+
const results = await filter({ recordCategoryName: { $contains: 'Medische' } }, adminToken);
|
|
266
|
+
expect(results).toContain(medicalOrg.id);
|
|
267
|
+
expect(results).not.toContain(consentOrg.id);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test('Matches questionnaire name regardless of the language of a translated name', async () => {
|
|
271
|
+
const { adminToken } = await initPlatformAdmin();
|
|
272
|
+
|
|
273
|
+
// A name that is stored as a multi-language object (not a plain string)
|
|
274
|
+
const org = await createOrganizationWithRecordCategories('Multilingual Questionnaire Club', [
|
|
275
|
+
RecordCategory.create({
|
|
276
|
+
name: TranslatedString.create({ nl: 'Gezondheidsvragen', en: 'Health questions' }),
|
|
277
|
+
}),
|
|
278
|
+
]);
|
|
279
|
+
|
|
280
|
+
expect(await filter({ recordCategoryName: { $contains: 'Gezondheidsvragen' } }, adminToken)).toContain(org.id);
|
|
281
|
+
expect(await filter({ recordCategoryName: { $contains: 'Health questions' } }, adminToken)).toContain(org.id);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test('Does not match a record name or child category name when filtering on record category name', async () => {
|
|
285
|
+
const { adminToken } = await initPlatformAdmin();
|
|
286
|
+
|
|
287
|
+
const org = await createOrganizationWithRecordCategories('Precise Questionnaire Club', [
|
|
288
|
+
RecordCategory.create({
|
|
289
|
+
name: TranslatedString.create('Algemeen'),
|
|
290
|
+
childCategories: [
|
|
291
|
+
RecordCategory.create({
|
|
292
|
+
name: TranslatedString.create('Subcategorie'),
|
|
293
|
+
records: [createTextRecord('Bloedgroep')],
|
|
294
|
+
}),
|
|
295
|
+
],
|
|
296
|
+
}),
|
|
297
|
+
]);
|
|
298
|
+
|
|
299
|
+
// The top-level questionnaire name matches
|
|
300
|
+
expect(await filter({ recordCategoryName: { $contains: 'Algemeen' } }, adminToken)).toContain(org.id);
|
|
301
|
+
// A child category name is not a questionnaire
|
|
302
|
+
expect(await filter({ recordCategoryName: { $contains: 'Subcategorie' } }, adminToken)).not.toContain(org.id);
|
|
303
|
+
// A record name is not a questionnaire
|
|
304
|
+
expect(await filter({ recordCategoryName: { $contains: 'Bloedgroep' } }, adminToken)).not.toContain(org.id);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test('Filters organizations by a record name in a top-level record categories', async () => {
|
|
308
|
+
const { adminToken } = await initPlatformAdmin();
|
|
309
|
+
|
|
310
|
+
const org = await createOrganizationWithRecordCategories('Direct Question Club', [
|
|
311
|
+
RecordCategory.create({
|
|
312
|
+
name: TranslatedString.create('Algemeen'),
|
|
313
|
+
records: [createTextRecord('Bloedgroep')],
|
|
314
|
+
}),
|
|
315
|
+
]);
|
|
316
|
+
const otherOrg = await createOrganizationWithRecordCategories('Other Question Club', [
|
|
317
|
+
RecordCategory.create({
|
|
318
|
+
name: TranslatedString.create('Algemeen'),
|
|
319
|
+
records: [createTextRecord('Schoenmaat')],
|
|
320
|
+
}),
|
|
321
|
+
]);
|
|
322
|
+
|
|
323
|
+
const results = await filter({ recordName: { $contains: 'Bloedgroep' } }, adminToken);
|
|
324
|
+
expect(results).toContain(org.id);
|
|
325
|
+
expect(results).not.toContain(otherOrg.id);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test('Filters organizations by a record name nested in deep child categories', async () => {
|
|
329
|
+
const { adminToken } = await initPlatformAdmin();
|
|
330
|
+
|
|
331
|
+
// Category -> child -> child -> child, with the record at the deepest level
|
|
332
|
+
const org = await createOrganizationWithRecordCategories('Deeply Nested Question Club', [
|
|
333
|
+
RecordCategory.create({
|
|
334
|
+
name: TranslatedString.create('Niveau 0'),
|
|
335
|
+
childCategories: [
|
|
336
|
+
RecordCategory.create({
|
|
337
|
+
name: TranslatedString.create('Niveau 1'),
|
|
338
|
+
childCategories: [
|
|
339
|
+
RecordCategory.create({
|
|
340
|
+
name: TranslatedString.create('Niveau 2'),
|
|
341
|
+
childCategories: [
|
|
342
|
+
RecordCategory.create({
|
|
343
|
+
name: TranslatedString.create('Niveau 3'),
|
|
344
|
+
records: [createTextRecord('Diepe vraag')],
|
|
345
|
+
}),
|
|
346
|
+
],
|
|
347
|
+
}),
|
|
348
|
+
],
|
|
349
|
+
}),
|
|
350
|
+
],
|
|
351
|
+
}),
|
|
352
|
+
]);
|
|
353
|
+
|
|
354
|
+
const noMatchOrg = await createOrganizationWithRecordCategories('Shallow Question Club', [
|
|
355
|
+
RecordCategory.create({
|
|
356
|
+
name: TranslatedString.create('Niveau 0'),
|
|
357
|
+
records: [createTextRecord('Oppervlakkige vraag')],
|
|
358
|
+
}),
|
|
359
|
+
]);
|
|
360
|
+
|
|
361
|
+
const results = await filter({ recordName: { $contains: 'Diepe vraag' } }, adminToken);
|
|
362
|
+
expect(results).toContain(org.id);
|
|
363
|
+
expect(results).not.toContain(noMatchOrg.id);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test('Filters organizations by a child (sub)category name', async () => {
|
|
367
|
+
const { adminToken } = await initPlatformAdmin();
|
|
368
|
+
|
|
369
|
+
const org = await createOrganizationWithRecordCategories('Child Category Club', [
|
|
370
|
+
RecordCategory.create({
|
|
371
|
+
name: TranslatedString.create('Algemeen'),
|
|
372
|
+
childCategories: [
|
|
373
|
+
RecordCategory.create({
|
|
374
|
+
name: TranslatedString.create('Medische subcategorie'),
|
|
375
|
+
records: [createTextRecord('Bloedgroep')],
|
|
376
|
+
}),
|
|
377
|
+
],
|
|
378
|
+
}),
|
|
379
|
+
]);
|
|
380
|
+
const otherOrg = await createOrganizationWithRecordCategories('Other Child Category Club', [
|
|
381
|
+
RecordCategory.create({
|
|
382
|
+
name: TranslatedString.create('Algemeen'),
|
|
383
|
+
childCategories: [
|
|
384
|
+
RecordCategory.create({
|
|
385
|
+
name: TranslatedString.create('Sportieve subcategorie'),
|
|
386
|
+
records: [createTextRecord('Bloedgroep')],
|
|
387
|
+
}),
|
|
388
|
+
],
|
|
389
|
+
}),
|
|
390
|
+
]);
|
|
391
|
+
|
|
392
|
+
const results = await filter({ recordChildCategoryName: { $contains: 'Medische subcategorie' } }, adminToken);
|
|
393
|
+
expect(results).toContain(org.id);
|
|
394
|
+
expect(results).not.toContain(otherOrg.id);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test('Does not match a top-level questionnaire name or record name when filtering on child category name', async () => {
|
|
398
|
+
const { adminToken } = await initPlatformAdmin();
|
|
399
|
+
|
|
400
|
+
const org = await createOrganizationWithRecordCategories('Precise Child Category Club', [
|
|
401
|
+
RecordCategory.create({
|
|
402
|
+
name: TranslatedString.create('Bovenliggende naam'),
|
|
403
|
+
childCategories: [
|
|
404
|
+
RecordCategory.create({
|
|
405
|
+
name: TranslatedString.create('Onderliggende naam'),
|
|
406
|
+
records: [createTextRecord('Vraagnaam')],
|
|
407
|
+
}),
|
|
408
|
+
],
|
|
409
|
+
}),
|
|
410
|
+
]);
|
|
411
|
+
|
|
412
|
+
// The child category name matches
|
|
413
|
+
expect(await filter({ recordChildCategoryName: { $contains: 'Onderliggende naam' } }, adminToken)).toContain(org.id);
|
|
414
|
+
// A top-level questionnaire name is not a child category
|
|
415
|
+
expect(await filter({ recordChildCategoryName: { $contains: 'Bovenliggende naam' } }, adminToken)).not.toContain(org.id);
|
|
416
|
+
// A record name is not a child category
|
|
417
|
+
expect(await filter({ recordChildCategoryName: { $contains: 'Vraagnaam' } }, adminToken)).not.toContain(org.id);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test('Filters organizations by a child category name nested in deep child categories', async () => {
|
|
421
|
+
const { adminToken } = await initPlatformAdmin();
|
|
422
|
+
|
|
423
|
+
// Category -> child -> child -> child, with the matching child category at the deepest level
|
|
424
|
+
const org = await createOrganizationWithRecordCategories('Deeply Nested Child Category Club', [
|
|
425
|
+
RecordCategory.create({
|
|
426
|
+
name: TranslatedString.create('Niveau 0'),
|
|
427
|
+
childCategories: [
|
|
428
|
+
RecordCategory.create({
|
|
429
|
+
name: TranslatedString.create('Niveau 1'),
|
|
430
|
+
childCategories: [
|
|
431
|
+
RecordCategory.create({
|
|
432
|
+
name: TranslatedString.create('Niveau 2'),
|
|
433
|
+
childCategories: [
|
|
434
|
+
RecordCategory.create({
|
|
435
|
+
name: TranslatedString.create('Diepe subcategorie'),
|
|
436
|
+
records: [createTextRecord('Diepe vraag')],
|
|
437
|
+
}),
|
|
438
|
+
],
|
|
439
|
+
}),
|
|
440
|
+
],
|
|
441
|
+
}),
|
|
442
|
+
],
|
|
443
|
+
}),
|
|
444
|
+
]);
|
|
445
|
+
|
|
446
|
+
const noMatchOrg = await createOrganizationWithRecordCategories('Shallow Child Category Club', [
|
|
447
|
+
RecordCategory.create({
|
|
448
|
+
name: TranslatedString.create('Niveau 0'),
|
|
449
|
+
childCategories: [
|
|
450
|
+
RecordCategory.create({
|
|
451
|
+
name: TranslatedString.create('Oppervlakkige subcategorie'),
|
|
452
|
+
}),
|
|
453
|
+
],
|
|
454
|
+
}),
|
|
455
|
+
]);
|
|
456
|
+
|
|
457
|
+
const results = await filter({ recordChildCategoryName: { $contains: 'Diepe subcategorie' } }, adminToken);
|
|
458
|
+
expect(results).toContain(org.id);
|
|
459
|
+
expect(results).not.toContain(noMatchOrg.id);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
test('Filters organizations by document template type', async () => {
|
|
463
|
+
const { adminToken } = await initPlatformAdmin();
|
|
464
|
+
|
|
465
|
+
const fiscalOrg = await new OrganizationFactory({ name: 'Fiscal Document Club' }).create();
|
|
466
|
+
await new DocumentTemplateFactory({ groups: [], organizationId: fiscalOrg.id, type: 'fiscal' }).create();
|
|
467
|
+
|
|
468
|
+
const participationOrg = await new OrganizationFactory({ name: 'Participation Document Club' }).create();
|
|
469
|
+
await new DocumentTemplateFactory({ groups: [], organizationId: participationOrg.id, type: 'participation' }).create();
|
|
470
|
+
|
|
471
|
+
const results = await filter({ documentTemplates: { $elemMatch: { type: { $in: ['fiscal'] } } } }, adminToken);
|
|
472
|
+
expect(results).toContain(fiscalOrg.id);
|
|
473
|
+
expect(results).not.toContain(participationOrg.id);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
test('Filters organizations by document template year', async () => {
|
|
477
|
+
const { adminToken } = await initPlatformAdmin();
|
|
478
|
+
|
|
479
|
+
const org2024 = await new OrganizationFactory({ name: 'Document Year 2024 Club' }).create();
|
|
480
|
+
await new DocumentTemplateFactory({ groups: [], organizationId: org2024.id, year: 2024 }).create();
|
|
481
|
+
|
|
482
|
+
const org2023 = await new OrganizationFactory({ name: 'Document Year 2023 Club' }).create();
|
|
483
|
+
await new DocumentTemplateFactory({ groups: [], organizationId: org2023.id, year: 2023 }).create();
|
|
484
|
+
|
|
485
|
+
const results = await filter({ documentTemplates: { $elemMatch: { year: { $eq: 2024 } } } }, adminToken);
|
|
486
|
+
expect(results).toContain(org2024.id);
|
|
487
|
+
expect(results).not.toContain(org2023.id);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
test('Filters organizations by document template status', async () => {
|
|
491
|
+
const { adminToken } = await initPlatformAdmin();
|
|
492
|
+
|
|
493
|
+
const publishedOrg = await new OrganizationFactory({ name: 'Published Document Club' }).create();
|
|
494
|
+
await new DocumentTemplateFactory({ groups: [], organizationId: publishedOrg.id, status: DocumentStatus.Published }).create();
|
|
495
|
+
|
|
496
|
+
const draftOrg = await new OrganizationFactory({ name: 'Draft Document Club' }).create();
|
|
497
|
+
await new DocumentTemplateFactory({ groups: [], organizationId: draftOrg.id, status: DocumentStatus.Draft }).create();
|
|
498
|
+
|
|
499
|
+
const results = await filter({ documentTemplates: { $elemMatch: { status: { $in: [DocumentStatus.Published] } } } }, adminToken);
|
|
500
|
+
expect(results).toContain(publishedOrg.id);
|
|
501
|
+
expect(results).not.toContain(draftOrg.id);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
test('Filters organizations by document template isLocked', async () => {
|
|
505
|
+
const { adminToken } = await initPlatformAdmin();
|
|
506
|
+
|
|
507
|
+
const lockedOrg = await new OrganizationFactory({ name: 'Locked Document Club' }).create();
|
|
508
|
+
await new DocumentTemplateFactory({ groups: [], organizationId: lockedOrg.id, isLocked: true }).create();
|
|
509
|
+
|
|
510
|
+
const unlockedOrg = await new OrganizationFactory({ name: 'Unlocked Document Club' }).create();
|
|
511
|
+
await new DocumentTemplateFactory({ groups: [], organizationId: unlockedOrg.id, isLocked: false }).create();
|
|
512
|
+
|
|
513
|
+
const results = await filter({ documentTemplates: { $elemMatch: { isLocked: { $eq: true } } } }, adminToken);
|
|
514
|
+
expect(results).toContain(lockedOrg.id);
|
|
515
|
+
expect(results).not.toContain(unlockedOrg.id);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
test('Filters organizations by document template updatesEnabled', async () => {
|
|
519
|
+
const { adminToken } = await initPlatformAdmin();
|
|
520
|
+
|
|
521
|
+
const noUpdatesOrg = await new OrganizationFactory({ name: 'No Updates Document Club' }).create();
|
|
522
|
+
await new DocumentTemplateFactory({ groups: [], organizationId: noUpdatesOrg.id, updatesEnabled: false }).create();
|
|
523
|
+
|
|
524
|
+
const updatesOrg = await new OrganizationFactory({ name: 'Updates Document Club' }).create();
|
|
525
|
+
await new DocumentTemplateFactory({ groups: [], organizationId: updatesOrg.id, updatesEnabled: true }).create();
|
|
526
|
+
|
|
527
|
+
const results = await filter({ documentTemplates: { $elemMatch: { updatesEnabled: { $eq: false } } } }, adminToken);
|
|
528
|
+
expect(results).toContain(noUpdatesOrg.id);
|
|
529
|
+
expect(results).not.toContain(updatesOrg.id);
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
test('Combines multiple document template conditions in a single $elemMatch', async () => {
|
|
533
|
+
const { adminToken } = await initPlatformAdmin();
|
|
534
|
+
|
|
535
|
+
const matchOrg = await new OrganizationFactory({ name: 'Matching Document Club' }).create();
|
|
536
|
+
await new DocumentTemplateFactory({ groups: [], organizationId: matchOrg.id, type: 'fiscal', year: 2025, status: DocumentStatus.Published }).create();
|
|
537
|
+
|
|
538
|
+
// Same org, but the conditions are split over two different templates: should not match a single-template $elemMatch
|
|
539
|
+
const splitOrg = await new OrganizationFactory({ name: 'Split Document Club' }).create();
|
|
540
|
+
await new DocumentTemplateFactory({ groups: [], organizationId: splitOrg.id, type: 'fiscal', year: 2020, status: DocumentStatus.Published }).create();
|
|
541
|
+
await new DocumentTemplateFactory({ groups: [], organizationId: splitOrg.id, type: 'participation', year: 2025, status: DocumentStatus.Published }).create();
|
|
542
|
+
|
|
543
|
+
const results = await filter({
|
|
544
|
+
documentTemplates: {
|
|
545
|
+
$elemMatch: {
|
|
546
|
+
type: { $in: ['fiscal'] },
|
|
547
|
+
year: { $eq: 2025 },
|
|
548
|
+
},
|
|
549
|
+
},
|
|
550
|
+
}, adminToken);
|
|
551
|
+
expect(results).toContain(matchOrg.id);
|
|
552
|
+
expect(results).not.toContain(splitOrg.id);
|
|
553
|
+
});
|
|
232
554
|
});
|
|
@@ -156,7 +156,9 @@ describe('Endpoint.PatchOrganizationMembersEndpoint', () => {
|
|
|
156
156
|
}).create();
|
|
157
157
|
|
|
158
158
|
const memberToPatch = await new MemberFactory({
|
|
159
|
-
|
|
159
|
+
// A typo of the existing member's first name, so correcting it (below) is allowed
|
|
160
|
+
// and still triggers the duplicate detection
|
|
161
|
+
firstName: firstName + 'n',
|
|
160
162
|
lastName,
|
|
161
163
|
birthDay,
|
|
162
164
|
generateData: true,
|
|
@@ -618,7 +620,8 @@ describe('Endpoint.PatchOrganizationMembersEndpoint', () => {
|
|
|
618
620
|
const patch = MemberWithRegistrationsBlob.patch({
|
|
619
621
|
id: member.id,
|
|
620
622
|
details: MemberDetails.patch({
|
|
621
|
-
|
|
623
|
+
// Changing the last name is always allowed; it proves the registration grants write access
|
|
624
|
+
lastName: 'Changed',
|
|
622
625
|
}),
|
|
623
626
|
});
|
|
624
627
|
arr.addPatch(patch);
|
|
@@ -639,7 +642,7 @@ describe('Endpoint.PatchOrganizationMembersEndpoint', () => {
|
|
|
639
642
|
expect(response.body.members.length).toBe(1);
|
|
640
643
|
const memberStruct = response.body.members[0];
|
|
641
644
|
expect(memberStruct.details).toMatchObject({
|
|
642
|
-
|
|
645
|
+
lastName: 'Changed',
|
|
643
646
|
});
|
|
644
647
|
});
|
|
645
648
|
|
|
@@ -801,6 +804,74 @@ describe('Endpoint.PatchOrganizationMembersEndpoint', () => {
|
|
|
801
804
|
});
|
|
802
805
|
});
|
|
803
806
|
|
|
807
|
+
describe('Name and birth date changes', () => {
|
|
808
|
+
test('A non-full admin cannot drastically change the first name of a member', async () => {
|
|
809
|
+
const organization = await new OrganizationFactory({}).create();
|
|
810
|
+
|
|
811
|
+
const group = await new GroupFactory({ organization }).create();
|
|
812
|
+
const resources = new Map();
|
|
813
|
+
resources.set(
|
|
814
|
+
PermissionsResourceType.Groups, new Map([[
|
|
815
|
+
group.id,
|
|
816
|
+
ResourcePermissions.create({ level: PermissionLevel.Write }),
|
|
817
|
+
]]),
|
|
818
|
+
);
|
|
819
|
+
|
|
820
|
+
const user = await new UserFactory({
|
|
821
|
+
permissions: Permissions.create({ level: PermissionLevel.None, resources }),
|
|
822
|
+
organization,
|
|
823
|
+
}).create();
|
|
824
|
+
|
|
825
|
+
const member = await new MemberFactory({ firstName, lastName, birthDay, generateData: false }).create();
|
|
826
|
+
await new RegistrationFactory({ member, group }).create();
|
|
827
|
+
|
|
828
|
+
const token = await Token.createToken(user);
|
|
829
|
+
|
|
830
|
+
const arr: Body = new PatchableArray();
|
|
831
|
+
arr.addPatch(MemberWithRegistrationsBlob.patch({
|
|
832
|
+
id: member.id,
|
|
833
|
+
details: MemberDetails.patch({ firstName: 'Michael' }),
|
|
834
|
+
}));
|
|
835
|
+
|
|
836
|
+
const request = Request.buildJson('PATCH', baseUrl, organization.getApiHost(), arr);
|
|
837
|
+
request.headers.authorization = 'Bearer ' + token.accessToken;
|
|
838
|
+
await expect(testServer.test(endpoint, request))
|
|
839
|
+
.rejects
|
|
840
|
+
.toThrow(STExpect.errorWithCode('not_allowed'));
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
test('A full admin can drastically change the name and birth date of a member', async () => {
|
|
844
|
+
const organization = await new OrganizationFactory({}).create();
|
|
845
|
+
|
|
846
|
+
const user = await new UserFactory({
|
|
847
|
+
permissions: Permissions.create({ level: PermissionLevel.Full }),
|
|
848
|
+
organization,
|
|
849
|
+
}).create();
|
|
850
|
+
|
|
851
|
+
const member = await new MemberFactory({ firstName, lastName, birthDay, generateData: false }).create();
|
|
852
|
+
await new RegistrationFactory({ member, organization }).create();
|
|
853
|
+
|
|
854
|
+
const token = await Token.createToken(user);
|
|
855
|
+
|
|
856
|
+
const base = member.details.birthDay!;
|
|
857
|
+
const newBirthDay = new Date(base.getFullYear() + 5, base.getMonth() + 4, base.getDate() + 6);
|
|
858
|
+
|
|
859
|
+
const arr: Body = new PatchableArray();
|
|
860
|
+
arr.addPatch(MemberWithRegistrationsBlob.patch({
|
|
861
|
+
id: member.id,
|
|
862
|
+
details: MemberDetails.patch({ firstName: 'Michael', birthDay: newBirthDay }),
|
|
863
|
+
}));
|
|
864
|
+
|
|
865
|
+
const request = Request.buildJson('PATCH', baseUrl, organization.getApiHost(), arr);
|
|
866
|
+
request.headers.authorization = 'Bearer ' + token.accessToken;
|
|
867
|
+
const response = await testServer.test(endpoint, request);
|
|
868
|
+
|
|
869
|
+
expect(response.status).toBe(200);
|
|
870
|
+
expect(response.body.members[0].details.firstName).toBe('Michael');
|
|
871
|
+
expect(response.body.members[0].details.birthDay?.getFullYear()).toBe(base.getFullYear() + 5);
|
|
872
|
+
});
|
|
873
|
+
});
|
|
874
|
+
|
|
804
875
|
describe('Record answers', () => {
|
|
805
876
|
test('An admin can set records of its own organization', async () => {
|
|
806
877
|
const commentsRecord = RecordSettings.create({
|
|
@@ -23,6 +23,7 @@ import { MemberNumberService } from '../../../services/MemberNumberService.js';
|
|
|
23
23
|
import { PlatformMembershipService } from '../../../services/PlatformMembershipService.js';
|
|
24
24
|
import { RegistrationService } from '../../../services/RegistrationService.js';
|
|
25
25
|
import { shouldCheckIfMemberIsDuplicateForPatch } from './shouldCheckIfMemberIsDuplicate.js';
|
|
26
|
+
import { throwIfDrasticMemberDetailsChange } from './throwIfDrasticMemberDetailsChange.js';
|
|
26
27
|
import { mergeTwoMembers } from '../../../helpers/MemberMerger.js';
|
|
27
28
|
|
|
28
29
|
type Params = Record<string, never>;
|
|
@@ -212,6 +213,11 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
|
|
|
212
213
|
|
|
213
214
|
shouldCheckDuplicate = shouldCheckIfMemberIsDuplicateForPatch(patch, originalDetails);
|
|
214
215
|
|
|
216
|
+
// Only full-permission admins may drastically change the name or birth date of an existing member
|
|
217
|
+
if (!await Context.auth.canAccessMember(member, PermissionLevel.Full)) {
|
|
218
|
+
throwIfDrasticMemberDetailsChange(patch.details, originalDetails);
|
|
219
|
+
}
|
|
220
|
+
|
|
215
221
|
const wasReduced = member.details.shouldApplyReducedPrice;
|
|
216
222
|
|
|
217
223
|
const originalReviewTimes = member.details.reviewTimes;
|