@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.
Files changed (43) hide show
  1. package/package.json +17 -17
  2. package/src/crons/index.ts +1 -0
  3. package/src/crons/stripe-payout-reports.ts +69 -0
  4. package/src/endpoints/admin/organizations/GetOrganizationsEndpoint.test.ts +324 -2
  5. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.test.ts +74 -3
  6. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +6 -0
  7. package/src/endpoints/global/members/throwIfDrasticMemberDetailsChange.test.ts +96 -0
  8. package/src/endpoints/global/members/throwIfDrasticMemberDetailsChange.ts +58 -0
  9. package/src/endpoints/global/registration/PatchUserMembersEndpoint.test.ts +68 -0
  10. package/src/endpoints/global/registration/PatchUserMembersEndpoint.ts +7 -1
  11. package/src/endpoints/global/registration/RegisterMembersEndpoint.test.ts +44 -3
  12. package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +31 -2
  13. package/src/endpoints/organization/dashboard/receivable-balances/ChargeReceivableBalancesEndpoint.ts +1 -1
  14. package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.test.ts +61 -2
  15. package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.ts +14 -1
  16. package/src/endpoints/organization/dashboard/stripe/GetStripePayoutsExportStatusEndpoint.ts +32 -0
  17. package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.test.ts +103 -0
  18. package/src/endpoints/organization/dashboard/stripe/StripePayoutsExportEndpoint.ts +125 -0
  19. package/src/helpers/AuthenticatedStructures.ts +5 -7
  20. package/src/helpers/MembershipCharger.ts +26 -1
  21. package/src/helpers/RegistrationPeriodRelationBackfiller.test.ts +111 -0
  22. package/src/helpers/RegistrationPeriodRelationBackfiller.ts +125 -0
  23. package/src/helpers/StripePayoutExportData.ts +195 -0
  24. package/src/helpers/StripePayoutExportExcel.ts +280 -0
  25. package/src/helpers/StripePayoutReporter.test.ts +419 -0
  26. package/src/helpers/StripePayoutReporter.ts +585 -0
  27. package/src/migrations/1783342463-remove-platform-uitpas-flag.ts +25 -0
  28. package/src/seeds/1783400000-backfill-registration-period-relation.ts +13 -0
  29. package/src/seeds/1783502528-fix-registrations-with-platform-period.ts +426 -0
  30. package/src/services/uitpas/UitpasService.ts +1 -1
  31. package/src/services/uitpas/cancelTicketSales.ts +1 -1
  32. package/src/services/uitpas/checkPermissionsFor.ts +1 -1
  33. package/src/services/uitpas/getSocialTariffForEvent.ts +1 -1
  34. package/src/services/uitpas/getSocialTariffForUitpasNumbers.ts +1 -1
  35. package/src/services/uitpas/registerTicketSales.ts +1 -1
  36. package/src/services/uitpas/searchUitpasEvents.ts +22 -18
  37. package/src/services/uitpas/searchUitpasOrganizers.ts +2 -1
  38. package/src/sql-filters/organizations.ts +52 -0
  39. package/tests/e2e/documents.test.ts +3 -1
  40. package/tests/vitest.global.setup.ts +1 -1
  41. package/tests/vitest.setup.ts +4 -2
  42. package/vitest.config.js +10 -0
  43. /package/src/seeds/{1780665427-schedule-stock-updates.ts → 1783515690-schedule-stock-updates.ts} +0 -0
@@ -0,0 +1,426 @@
1
+ import { Migration } from '@simonbackx/simple-database';
2
+ import { Group, Organization, OrganizationRegistrationPeriod, Registration, RegistrationPeriod, V1GroupMigrationData } from '@stamhoofd/models';
3
+ import { GroupCategory, GroupCategorySettings, GroupPrivateSettings, GroupSettings, GroupStatus, TranslatedString } from '@stamhoofd/structures';
4
+ import { SeedTools } from '../helpers/SeedTools.js';
5
+
6
+ export default new Migration(async () => {
7
+ if (STAMHOOFD.environment === 'test') {
8
+ console.log('skipped in tests');
9
+ return;
10
+ }
11
+
12
+ if (STAMHOOFD.platformName.toLowerCase() !== 'stamhoofd') {
13
+ console.log('skipped for platform (only runs for Stamhoofd): ' + STAMHOOFD.platformName);
14
+ return;
15
+ }
16
+
17
+ const dryRun = false;
18
+ await start(dryRun);
19
+
20
+ if (dryRun) {
21
+ throw new Error('Migration did not finish because of dryRun');
22
+ }
23
+ });
24
+
25
+ // organizations with registration where periodId is the id of the platform period
26
+ const relevantOrganizations = new Set([
27
+ '453bd0b1-f238-4a41-a270-99bb13c71f46', 'c69512bc-ea0c-427a-ab90-08c3dcf1c856', '2cec1ef3-80bc-43ad-929e-4a9ddb6d6d8d', 'ede34921-efd4-446b-93c5-aa9c38b05f8a', '235b03ae-6513-4fe6-a438-217887826a26', '8df666ab-c645-47f7-a171-c305ab556ecb', '971e1bac-2668-4d7a-870c-8007c47f878f', 'e04bcda4-b8d3-4897-b1fb-e565368167fa', 'd017c264-8ad2-42e7-b46a-4ba6bb3d4ca6', 'f0edb665-1ce0-442a-88e7-5a62ea78c5fc', '45fcc4f2-c406-45fc-aaaf-9bdc06d9e68d', 'ed6e74f1-51a0-4e04-8195-e722b015eeb5', '45743bb7-539e-41a3-885d-c8cc57e73ac6', '9a635b47-2189-4fb5-983c-15a1cdf05ffd', 'a2b6a2de-5021-4533-88db-f64c8c07d89d', '97928f8a-b8d1-44cc-918f-09d0ae896c3b', '57815733-5750-492f-beb1-1efd6229803a', '0a78224a-2ec4-42d7-a5e5-e29b2732c6d7', '28f1a27e-1edd-4169-b98f-3ae0f8bb806f', 'eb6ccffa-41c5-45df-b4e4-1eb5749bc0fe', '27e06924-49a6-468c-a16e-b5735ace1894', '684c20b1-de07-40a4-8a92-5a7d30f32132', '3806a968-c55c-46e5-a674-95dd7b7f6f3f', 'd4b73261-a2d8-4f85-854e-38373012b22d',
28
+ ]);
29
+
30
+ // groups with registration where periodId is the id of the platform period
31
+ const relevantGroups = new Set([
32
+ 'e6b18bcc-181a-4d7c-97c4-89ac2a846b80', '402ad45d-3cf1-49cb-88f0-3000df02fbe0', 'a768538a-942d-4adb-819a-f2915aabb205', '651c8b41-9760-43f1-ab7a-177c860dde28', '0ca82d1c-75fb-4096-91be-c9331e9e86e4', '5bf1c5e9-1557-4d33-a4b8-5040ec115ad3', '476c3373-81c1-47d2-a2ad-7f0e545a6207', 'e86b908b-3a9a-4974-b839-5c3e83ec1590', 'ceecef6a-e537-4954-96e5-c6c9904bec74', '60008a98-0cb2-45a2-8346-80ab7a6155dd', '0fd87a5d-2234-4b94-9d54-a663ba8c6a11', '83ab5758-d9a9-4675-9667-7735c6097446', '0e50b08a-93c7-4917-985e-5725687bcc26', '3dea4b34-4183-4e82-aa33-0ba8b61737f0', '1bf7ab25-bd12-4315-afc3-49be1878ad02', '6c04eb42-e9f6-4931-a862-e671be5f7495', 'a43054fb-9efb-46ac-b49b-e86fca343c10', 'c2757b10-3012-483b-abf0-c639ed55bb7c', 'adb253da-2696-45bd-9743-889eb52e953c', '387d06fc-771a-4b4b-b69a-f72827a49a51', '29b955ec-5987-4e25-8d20-1f6cfbfdd198', 'c85cff0f-3798-4d32-8067-8026ada0bb9c', 'e0b4a178-a682-4eb2-b720-bf82c80bfb34', 'c75af993-c9ae-41b5-83f3-5c30bf61c74e', 'd910d04c-90e2-4079-a997-316141f4739d', '3b81c7d9-5a4a-4323-a152-0219755f164f', '7b1767d5-fef5-4520-8748-5c2e3e1e97e5', 'f322530a-03ab-4b4a-b5c4-1a98608b12ee', '3c9b619d-c210-4fc9-950b-3a808e942ad1', '6548de8b-3b02-469f-87f5-a9e661f29ba0', '03e9c564-da0f-4a08-b843-13d830b3e061', 'f9e398df-4d26-4efb-af79-310aecde6678', '7f9310af-a48b-46c5-8a7f-d125d66ee9cf', '09c264e9-f327-4ccf-bf12-dd412cd23c1b', '765517b2-65df-446a-b9ad-7a7281f91af6', '9cea7eb7-6aac-4627-b6bb-7a117e99652c', '525f0600-aff5-47c7-9df4-6a18b0cfec2f', 'c9374388-e187-4dae-9f48-bee17fff4975', '60ff9fc5-b32f-4ee5-9532-54fcecbf00ba', 'bdbe1e9c-c25a-48f2-b506-d554d58cb02a', '47988375-8aef-45e9-8a5c-9a21a51f5c6b', '99dfd728-991f-408f-9b77-ad9f736600ab', 'a8d4845a-ecdb-4f2f-b66d-a6955e30c3db', '55d32f3b-6396-488e-98c2-1f1e9d37a7d6', '27842912-673a-42a6-9c2a-2285da36c5b4', '1667c735-bb37-4402-beda-aed88066abe3', '60943cd6-1498-456f-b540-413d7125c88f', '39a21999-4923-48ec-9118-5b98232543e5', '4952282e-dc63-4c79-b63b-ddbe3f9006fa', '887827d1-db4d-40ac-8bf4-6fc64910f13a', 'c9e4a223-cd65-4545-a322-400d84ece455', '5d0916ea-8f13-4f15-bf98-0358e527890d', '77d4eb10-e3b8-4e8e-bb06-272d085ff516', '790ae8d1-e98d-4bb2-b2cc-a77a21e8c712', '1599bb4e-9723-4a90-a36a-4605644983be', '6bb1dcad-ee5e-44e6-ab09-6ae381871566', 'd201de16-7bc7-4f98-a22b-5c335b2c872f', '090b0c1d-1481-4535-9034-219dbe4eb35c', 'b2831403-d272-46f4-9f2a-6084165c482e', 'ade7eda4-72c6-48e4-ac66-4b7c9a68f5cb', '09c3ac0f-ee98-4c11-a7fb-4601daf15ea1', '6d89b200-cf8d-4d4b-9cd7-ed65d3cd8c57', '820b1a0e-2c80-4402-94ad-c0e873d127f9', '094db6ad-40d9-4b0d-9670-74b6de99c0ca', '21b42f15-de4e-4c16-8ea0-ea83fbc3ba44', '2d2a3ab8-7bd9-4877-9122-23dc2435b73f', '7974762f-b0f3-4010-8b08-5ce1e45a14a8', '374c994e-85b9-4d22-92f4-3a094ae5fc5f', 'b81547bf-1a5f-4443-8a41-1f0e8f9e5ff4', 'a45fddb7-5dae-4330-907f-584f788bde14', '4828698d-6a8a-4937-bfe8-ca4b55d9d5a7', '2b3a9502-1943-4c00-baf2-24b8dcd191f2', '7b402e0d-219d-430e-9ebf-d2471bbb6629', 'd2626510-32f1-4677-8bbe-f3b31b529221', 'f4038ea4-cf03-4677-babc-d525f73d7378', '7e225069-3e49-486a-8187-f26947567bc6', '8d1f6366-8f28-47b9-b616-9afcc4575fc4', '17bd4f4a-203d-430d-a4b0-03aa8f9b9015', '968619a9-1483-4beb-931d-4f819cef674a', '68f958e1-491d-4f26-ad61-1598500202d8', '6848ce16-e637-4f4b-8a25-75f958654ee2', '9419df04-4181-4780-abc5-dcfc0d705c12', '9fa735ba-00de-4eac-b659-73966415f60f', '6ec3ea7e-0cbf-4b4a-9177-98c8bffd6cfe', 'c36b43c9-25c9-4820-a2e8-de256b165a33', '56589baa-e4a1-4ba0-921b-ae9f621cbf04', '8ab04173-37c9-48df-a8f1-f1ebbe7fd230', 'bb06cec4-a028-4e91-b177-53aa596baffc', '139c1164-672c-4d33-8bc9-c29893793adf', 'ecf914c4-84c8-4ac8-80df-e15283bbd270', 'bf16f791-4c05-418c-a6b2-129f43bf2567', '912c1022-065d-4bbd-a750-ba5356016ae3', '494882e1-8933-4e39-9523-180d250668dd',
33
+ ]);
34
+
35
+ async function start(dryRun: boolean) {
36
+ await SeedTools.loop({
37
+ query: Organization.select().where('id', Array.from(relevantOrganizations)),
38
+ batchSize: 1,
39
+ useTransactionPerBatch: true,
40
+ action: async (organization: Organization) => {
41
+ if (!relevantOrganizations.has(organization.id)) {
42
+ return;
43
+ }
44
+
45
+ const groups: Group[] = await Group.select()
46
+ .where('organizationId', organization.id)
47
+ .where('id', Array.from(relevantGroups))
48
+ .fetch();
49
+
50
+ for (const group of groups) {
51
+ if (!relevantGroups.has(group.id)) {
52
+ continue;
53
+ }
54
+
55
+ await fixRegistrations(organization, group, dryRun);
56
+ }
57
+ },
58
+
59
+ });
60
+ }
61
+
62
+ async function getOriginalGroupCycle(group: Group): Promise<number> {
63
+ const migrationData = await V1GroupMigrationData.select().where('oldGroupId', group.id).andWhere('newGroupId', group.id).first(true);
64
+ return migrationData.oldCycle;
65
+ }
66
+ async function getNewGroupForCycle(oldGroupId: string, cycle: number) {
67
+ const migrationData = await V1GroupMigrationData.select().where('oldGroupId', oldGroupId).andWhere('oldCycle', cycle).first(false);
68
+ if (!migrationData) {
69
+ return null;
70
+ }
71
+
72
+ const newGroupId = migrationData.newGroupId;
73
+ const group = await Group.getByID(newGroupId);
74
+ if (!group) {
75
+ throw new Error('group not found');
76
+ }
77
+ return group;
78
+ }
79
+
80
+ async function fixRegistrations(organization: Organization, group: Group, dryRun: boolean) {
81
+ const originalGroupCycle = await getOriginalGroupCycle(group);
82
+
83
+ const registrations = await Registration.select()
84
+ .where('groupId', group.id)
85
+ // where cycle is different from group
86
+ .whereNot('cycle', originalGroupCycle)
87
+ .fetch();
88
+
89
+ const cycleMap = new Map<number, Registration[]>();
90
+
91
+ // group registrations by cycle
92
+ for (const registration of registrations) {
93
+ let registrations = cycleMap.get(registration.cycle);
94
+ if (!registrations) {
95
+ registrations = [];
96
+ cycleMap.set(registration.cycle, registrations);
97
+ }
98
+
99
+ registrations.push(registration);
100
+ }
101
+
102
+ const archivePeriod = await RegistrationPeriod.select().where('organizationId', group.organizationId).where('customName', 'Gearchiveerde periodes').first(true);
103
+
104
+ const createdGroups: Group[] = [];
105
+
106
+ for (const [cycle, registrations] of cycleMap) {
107
+ // find new group
108
+ const newGroup = await getNewGroupForCycle(group.id, cycle);
109
+
110
+ if (newGroup) {
111
+ await migrateRegistrations({ registrations, newGroup, cycle }, dryRun);
112
+ } else {
113
+ console.log(`missing group for ${group.settings.name.toString()} cycle ${cycle}`);
114
+
115
+ const numberOfPeriodsAgo = originalGroupCycle - cycle;
116
+ const newGroup = createPreviousGroup({ originalGroup: group, period: archivePeriod, numberOfPeriodsAgo });
117
+
118
+ if (!dryRun) {
119
+ const migrationData = new V1GroupMigrationData();
120
+ migrationData.oldGroupId = group.id;
121
+ migrationData.oldCycle = cycle;
122
+
123
+ await newGroup.save();
124
+ migrationData.newGroupId = newGroup.id;
125
+ await migrationData.save();
126
+ }
127
+
128
+ createdGroups.push(newGroup);
129
+
130
+ await migrateRegistrations({ registrations, newGroup, cycle }, dryRun);
131
+ }
132
+ }
133
+
134
+ if (createdGroups.length > 0) {
135
+ const archiveOrganizationPeriod = await OrganizationRegistrationPeriod.select().where('organizationId', organization.id).where('periodId', archivePeriod.id).first(true);
136
+ const parentCategory = await findCategory(group, archiveOrganizationPeriod);
137
+
138
+ if (parentCategory) {
139
+ for (const newGroup of createdGroups) {
140
+ if (newGroup.id === undefined && dryRun) {
141
+ continue;
142
+ }
143
+
144
+ if (!parentCategory.groupIds.includes(newGroup.id)) {
145
+ parentCategory.groupIds.push(newGroup.id);
146
+ } else {
147
+ throw new Error('group already in category:' + newGroup.id);
148
+ }
149
+
150
+ await cleanupGroup(newGroup, dryRun);
151
+ }
152
+ } else {
153
+ const { path, organizationPeriod: currentOrganizationPeriod } = await findPath(group);
154
+ const allArchivedCategories = archiveOrganizationPeriod.settings.categories;
155
+
156
+ const archivedRoot = archiveOrganizationPeriod.settings.rootCategory;
157
+ if (!archivedRoot) {
158
+ throw new Error('archived root category not found');
159
+ }
160
+ let thisLayer = getChildCategories(archivedRoot, allArchivedCategories);
161
+ const equalArchivedPath: GroupCategory[] = [archiveOrganizationPeriod.settings.rootCategory!];
162
+
163
+ for (const currentPeriodCategory of path) {
164
+ if (currentPeriodCategory === currentOrganizationPeriod.settings.rootCategory) {
165
+ continue;
166
+ }
167
+
168
+ // only exact match for last category is needed
169
+ const exactMatch = currentPeriodCategory !== path[path.length - 1];
170
+ let equalArchivedCategories = thisLayer.filter(archivedCategory => isEqualCategory(currentPeriodCategory, archivedCategory, exactMatch));
171
+
172
+ if (equalArchivedCategories.length > 1) {
173
+ const exactMatches = equalArchivedCategories.filter(archivedCategory => isEqualCategory(currentPeriodCategory, archivedCategory, true));
174
+
175
+ if (exactMatches.length === 0) {
176
+ throw new Error(`Found multiple equal categories but no exact match (${currentPeriodCategory.settings.name}): ` + equalArchivedCategories.map(c => c.settings.name).join(', '));
177
+ }
178
+
179
+ if (exactMatches.length > 1) {
180
+ equalArchivedCategories = [await getCategoryWhereGroupHasSameOriginalGroup(exactMatches, group)];
181
+ } else {
182
+ equalArchivedCategories = exactMatches;
183
+ }
184
+ }
185
+
186
+ if (equalArchivedCategories.length === 0) {
187
+ break;
188
+ }
189
+
190
+ const equalArchivedCategory = equalArchivedCategories[0];
191
+ thisLayer = getChildCategories(equalArchivedCategory, allArchivedCategories);
192
+ equalArchivedPath.push(equalArchivedCategory);
193
+ }
194
+
195
+ // logging
196
+ console.log('organization: ', organization.name);
197
+ console.log('group: ', group.settings.name.toString());
198
+ logPath(path, 'current');
199
+ logPath(equalArchivedPath, 'archived');
200
+
201
+ if (path.length === equalArchivedPath.length) {
202
+ // complete match -> add group to last category
203
+ const lastCategory = equalArchivedPath[equalArchivedPath.length - 1];
204
+
205
+ if (lastCategory.groupIds.length) {
206
+ // add created groups to last category
207
+ for (const createdGroup of createdGroups) {
208
+ if (!lastCategory.groupIds.includes(createdGroup.id)) {
209
+ lastCategory.groupIds.push(createdGroup.id);
210
+ }
211
+ }
212
+ } else {
213
+ // create new category
214
+ const newCategory = createCategoryForOriginalGroup(group, createdGroups.map(g => g.id));
215
+ lastCategory.categoryIds.push(newCategory.id);
216
+ archiveOrganizationPeriod.settings.categories.push(newCategory);
217
+ }
218
+ } else {
219
+ // should not happen
220
+ throw new Error('no complete match');
221
+ }
222
+ }
223
+
224
+ if (!dryRun) {
225
+ await archiveOrganizationPeriod.save();
226
+ }
227
+ }
228
+
229
+ if (!dryRun) {
230
+ await group.updateOccupancy();
231
+ await group.save();
232
+ }
233
+ }
234
+
235
+ async function getCategoryWhereGroupHasSameOriginalGroup(exactMatches: GroupCategory[], originalGroup: Group) {
236
+ for (const category of exactMatches) {
237
+ const migrationData = await V1GroupMigrationData.select().where('newGroupId', category.groupIds).andWhere('oldGroupId', originalGroup.id).first(false);
238
+ if (migrationData) {
239
+ return category;
240
+ }
241
+ }
242
+
243
+ throw new Error('no category found');
244
+ }
245
+
246
+ function createCategoryForOriginalGroup(originalGroup: Group, groupIds: string[]) {
247
+ return GroupCategory.create({
248
+ settings: GroupCategorySettings.create({
249
+ name: originalGroup.settings.name.toString(),
250
+ public: false,
251
+ maximumRegistrations: null,
252
+ }),
253
+ groupIds,
254
+ });
255
+ }
256
+
257
+ function getChildCategories(category: GroupCategory, allCategories: GroupCategory[]) {
258
+ const results: GroupCategory[] = [];
259
+
260
+ for (const childId of category.categoryIds) {
261
+ const child = allCategories.find(c => c.id === childId);
262
+ if (!child) {
263
+ throw new Error('child category not found');
264
+ }
265
+ results.push(child);
266
+ }
267
+
268
+ return results;
269
+ }
270
+
271
+ function logPath(path: GroupCategory[], prefix: string): void {
272
+ console.log(`${prefix} path: ${path.map(c => c.settings.name).join(' > ')}`);
273
+ }
274
+
275
+ function isEqualCategory(currentPeriodCategory: GroupCategory, archivedPeriodCategory: GroupCategory, exactMatch: boolean) {
276
+ if (exactMatch) {
277
+ return archivedPeriodCategory.settings.name === currentPeriodCategory.settings.name;
278
+ }
279
+ return archivedPeriodCategory.settings.name.startsWith(currentPeriodCategory.settings.name);
280
+ }
281
+
282
+ /**
283
+ * Find the path from the old group to the root category.
284
+ */
285
+ async function findPath(oldGroup: Group): Promise<{ path: GroupCategory[]; organizationPeriod: OrganizationRegistrationPeriod }> {
286
+ /**
287
+ * Returns all the parent categories of the group starting with the root category (= start) or null if not found.
288
+ */
289
+ function getPathToGroup(groupId: string, start: GroupCategory, allCategories: GroupCategory[]): GroupCategory[] | null {
290
+ if (start.groupIds.includes(groupId)) {
291
+ return [start];
292
+ }
293
+
294
+ for (const childCategoryId of start.categoryIds) {
295
+ const childCategory = allCategories.find(c => c.id === childCategoryId);
296
+ if (childCategory) {
297
+ const childPath = getPathToGroup(groupId, childCategory, allCategories);
298
+ if (childPath !== null) {
299
+ return [start, ...childPath];
300
+ }
301
+ }
302
+ }
303
+
304
+ return null;
305
+ }
306
+
307
+ const organizationPeriod = await OrganizationRegistrationPeriod.select()
308
+ .where('organizationId', oldGroup.organizationId)
309
+ .where('periodId', oldGroup.periodId)
310
+ .first(true);
311
+
312
+ const root = organizationPeriod.settings.rootCategory;
313
+ if (!root) {
314
+ throw new Error('root category not found');
315
+ }
316
+
317
+ const path = getPathToGroup(oldGroup.id, root, organizationPeriod.settings.categories);
318
+ if (path === null) {
319
+ throw new Error('path not found');
320
+ }
321
+
322
+ return {
323
+ organizationPeriod,
324
+ path,
325
+ };
326
+ }
327
+
328
+ async function cleanupGroup(group: Group, dryRun: boolean) {
329
+ group.settings.cycleSettings = new Map();
330
+ group.settings.preventPreviousGroupIds = [];
331
+ group.settings.requirePreviousGroupIds = [];
332
+ group.cycle = cycleIfMigrated;
333
+ if (group.status === GroupStatus.Archived) {
334
+ group.status = GroupStatus.Closed;
335
+ }
336
+
337
+ if (!dryRun) {
338
+ await group.updateOccupancy();
339
+ await group.save();
340
+ }
341
+ }
342
+
343
+ async function migrateRegistrations({ registrations, newGroup, cycle }: { registrations: Registration[]; newGroup: Group; cycle: number }, dryRun: boolean) {
344
+ for (const registration of registrations) {
345
+ if (registration.waitingList) {
346
+ throw new Error('not supported');
347
+ } else {
348
+ registration.groupId = newGroup.id;
349
+ }
350
+
351
+ registration.periodId = newGroup.periodId;
352
+ registration.cycle = cycle;
353
+
354
+ if (!dryRun) {
355
+ await registration.save();
356
+ }
357
+ }
358
+ }
359
+
360
+ async function findCategory(group: Group, archiveOrganizationPeriod: OrganizationRegistrationPeriod) {
361
+ const previousGroups = await V1GroupMigrationData.select().where('oldGroupId', group.id).andWhereNot('newGroupId', group.id).fetch();
362
+
363
+ for (const previousGroup of previousGroups) {
364
+ const previousGroupId = previousGroup.newGroupId;
365
+ if (previousGroup.oldGroupId !== group.id || previousGroup.newGroupId === group.id) {
366
+ throw new Error('group id mismatch');
367
+ }
368
+
369
+ const allCategories = archiveOrganizationPeriod.settings.categories;
370
+ const rootCategory = archiveOrganizationPeriod.settings.rootCategory;
371
+ if (!rootCategory) {
372
+ throw new Error('root category not found for archiveOrganizationPeriod: ' + archiveOrganizationPeriod.id);
373
+ }
374
+
375
+ const parentCategory = allCategories.find(c => c.groupIds.includes(previousGroupId));
376
+ if (parentCategory) {
377
+ return parentCategory;
378
+ }
379
+ }
380
+
381
+ return null;
382
+ }
383
+
384
+ const cycleIfMigrated = -99;
385
+
386
+ function createPreviousGroup({ originalGroup, period, numberOfPeriodsAgo }: { originalGroup: Group; period: RegistrationPeriod; numberOfPeriodsAgo: number }) {
387
+ const newGroup = new Group();
388
+ newGroup.organizationId = originalGroup.organizationId;
389
+ newGroup.periodId = period.id;
390
+ newGroup.status = originalGroup.status;
391
+ newGroup.createdAt = originalGroup.createdAt;
392
+ newGroup.deletedAt = originalGroup.deletedAt;
393
+
394
+ const originalPrivateSettings: GroupPrivateSettings = originalGroup.privateSettings;
395
+
396
+ newGroup.privateSettings = GroupPrivateSettings.create({
397
+ ...originalPrivateSettings,
398
+ });
399
+
400
+ newGroup.cycle = cycleIfMigrated;
401
+
402
+ const originalSettings: GroupSettings = originalGroup.settings;
403
+
404
+ const periodStartDate: Date = period.startDate;
405
+ const periodEndDate: Date = period.endDate;
406
+
407
+ const startDate = new Date(periodStartDate);
408
+ const endDate = new Date(periodEndDate);
409
+
410
+ const isPlural = numberOfPeriodsAgo > 1;
411
+ const extraName = isPlural ? `${numberOfPeriodsAgo} periodes geleden` : `${numberOfPeriodsAgo} periode geleden`;
412
+
413
+ newGroup.settings = GroupSettings
414
+ .create({
415
+ ...originalSettings,
416
+ cycleSettings: new Map(),
417
+ name: new TranslatedString(`${originalSettings.name.toString()} (${extraName})`),
418
+ startDate,
419
+ endDate,
420
+ hasCustomDates: true,
421
+ period: period.getBaseStructure(),
422
+ });
423
+ newGroup.type = originalGroup.type;
424
+
425
+ return newGroup;
426
+ }
@@ -297,7 +297,7 @@ export class UitpasService {
297
297
  }
298
298
 
299
299
  static async registerAttendance() {
300
- // https://api-test.uitpas.be/checkins
300
+ // https://docs.publiq.be/docs/uitpas/uitpas-api/reference/operations/create-a-checkin
301
301
  }
302
302
 
303
303
  static async searchUitpasEvents(organizationId: string, uitpasOrganizerId: string, textQuery?: string) {
@@ -2,7 +2,7 @@ import { SimpleError } from '@simonbackx/simple-errors';
2
2
 
3
3
  async function cancelTicketSale(access_token: string, ticketSaleId: string) {
4
4
  // https://docs.publiq.be/docs/uitpas/uitpas-api/reference/operations/delete-a-ticket-sale
5
- const url = 'https://api-test.uitpas.be/ticket-sales/' + ticketSaleId;
5
+ const url = `${STAMHOOFD.UITPAS_API_URL}/ticket-sales/${ticketSaleId}`;
6
6
  const myHeaders = new Headers();
7
7
  myHeaders.append('Authorization', 'Bearer ' + access_token);
8
8
  myHeaders.append('Accept', 'application/json');
@@ -42,7 +42,7 @@ function assertIsPermissionsResponse(json: unknown): asserts json is Permissions
42
42
  }
43
43
 
44
44
  export async function checkPermissionsFor(access_token: string, organizationId: string | null, uitpasOrganizerId: string) {
45
- const url = 'https://api-test.uitpas.be/permissions';
45
+ const url = `${STAMHOOFD.UITPAS_API_URL}/permissions`;
46
46
  const myHeaders = new Headers();
47
47
  myHeaders.append('Authorization', 'Bearer ' + access_token);
48
48
  myHeaders.append('Accept', 'application/json');
@@ -27,7 +27,7 @@ function assertsIsStaticSocialTariffResponse(json: unknown): asserts json is Sta
27
27
  }
28
28
 
29
29
  export async function getSocialTariffForEvent(access_token: string, basePrice: number, uitpasEventUrl: string) {
30
- const baseUrl = 'https://api-test.uitpas.be/tariffs/static';
30
+ const baseUrl = `${STAMHOOFD.UITPAS_API_URL}/tariffs/static`;
31
31
  const params = new URLSearchParams();
32
32
  params.append('regularPrice', (basePrice / 10_000).toFixed(2));
33
33
  const eventId = uitpasEventUrl.split('/').pop(); // Extract the event ID from the URL
@@ -52,7 +52,7 @@ function isSocialTariffErrorResponse(
52
52
  }
53
53
 
54
54
  async function getSocialTariffForUitpasNumber(access_token: string, uitpasNumber: string, basePrice: number, uitpasEventUrl: string) {
55
- const baseUrl = 'https://api-test.uitpas.be/tariffs';
55
+ const baseUrl = `${STAMHOOFD.UITPAS_API_URL}/tariffs`;
56
56
  const params = new URLSearchParams();
57
57
  params.append('regularPrice', (basePrice / 1_0000).toFixed(2));
58
58
  const eventId = uitpasEventUrl.split('/').pop();
@@ -62,7 +62,7 @@ export async function registerTicketSales(access_token: string, registerTicketSa
62
62
  if (registerTicketSaleRequests.length === 0) {
63
63
  return new Map();
64
64
  };
65
- const url = 'https://api-test.uitpas.be/ticket-sales';
65
+ const url = `${STAMHOOFD.UITPAS_API_URL}/ticket-sales`;
66
66
  const body = registerTicketSaleRequests.map((ticketSale) => {
67
67
  const eventId = ticketSale.uitpasEventUrl.split('/').pop();
68
68
  if (!eventId) {
@@ -1,16 +1,27 @@
1
1
  import { UitpasEventResponse, UitpasEventsResponse } from '@stamhoofd/structures';
2
2
  import { SimpleError } from '@simonbackx/simple-errors';
3
3
 
4
+ /* Pick a translated string, prefer 'nl' */
5
+ function pickTranslation(value: unknown): string {
6
+ if (typeof value !== 'object' || value === null) {
7
+ return '';
8
+ }
9
+ const entries = Object.entries(value as Record<string, unknown>).filter((e): e is [string, string] => typeof e[1] === 'string');
10
+ if (entries.length === 0) {
11
+ return '';
12
+ }
13
+ const nl = entries.find(([key]) => key === 'nl');
14
+ return nl ? nl[1] : entries[0][1];
15
+ }
16
+
4
17
  type EventsResponse = {
5
18
  totalItems: number;
6
19
  itemsPerPage: number;
7
20
  member: Array<{
8
21
  '@id': string;
9
- 'name': {
10
- nl: string;
11
- };
22
+ 'name': Record<string, string>;
12
23
  'location': {
13
- name: object;
24
+ name: Record<string, string>;
14
25
  };
15
26
  'startDate'?: string;
16
27
  'endDate'?: string;
@@ -33,10 +44,7 @@ function assertIsEventsResponse(json: unknown): asserts json is EventsResponse {
33
44
  || !('@id' in member)
34
45
  || typeof member['@id'] !== 'string'
35
46
  || !('name' in member)
36
- || typeof member.name !== 'object'
37
- || member.name === null
38
- || !('nl' in member.name)
39
- || typeof member.name.nl !== 'string'
47
+ || pickTranslation(member.name) === ''
40
48
  || !('location' in member)
41
49
  || typeof member.location !== 'object'
42
50
  || member.location === null
@@ -111,6 +119,10 @@ export async function searchUitpasEvents(clientId: string, uitpasOrganizerId: st
111
119
  });
112
120
  });
113
121
 
122
+ return parseUitpasEventsResponse(json);
123
+ }
124
+
125
+ export function parseUitpasEventsResponse(json: unknown): UitpasEventsResponse {
114
126
  assertIsEventsResponse(json);
115
127
  const eventsResponse = new UitpasEventsResponse();
116
128
  eventsResponse.totalItems = json.totalItems;
@@ -118,16 +130,8 @@ export async function searchUitpasEvents(clientId: string, uitpasOrganizerId: st
118
130
  eventsResponse.member = json.member.map((member) => {
119
131
  const event = new UitpasEventResponse();
120
132
  event.url = member['@id'];
121
- event.name = member.name.nl;
122
- const locationName = member.location.name as Record<string, string>;
123
- const entrs = Object.entries(locationName);
124
- const hasNl = entrs.find(([key]) => key === 'nl');
125
- if (hasNl) {
126
- event.location = locationName.nl;
127
- } else {
128
- const lang = entrs[0];
129
- event.location = lang ? lang[1] : '';
130
- }
133
+ event.name = pickTranslation(member.name);
134
+ event.location = pickTranslation(member.location.name);
131
135
  if (member.startDate) {
132
136
  event.startDate = new Date(member.startDate);
133
137
  }
@@ -40,7 +40,7 @@ export async function searchUitpasOrganizers(access_token: string, name: string)
40
40
  human: $t(`%1Bb`),
41
41
  });
42
42
  }
43
- const baseUrl = 'https://api-test.uitpas.be/organizers';
43
+ const baseUrl = `${STAMHOOFD.UITPAS_API_URL}/organizers`;
44
44
  const params = new URLSearchParams();
45
45
  params.append('name', name);
46
46
  params.append('limit', '200');
@@ -63,6 +63,7 @@ export async function searchUitpasOrganizers(access_token: string, name: string)
63
63
  });
64
64
  });
65
65
  if (!response.ok) {
66
+ console.log(response.status, response.statusText, await response.text());
66
67
  throw new SimpleError({
67
68
  code: 'unsuccessful_response_searching_uitpas_organizers',
68
69
  message: `Unsuccessful response when searching for UiTPAS organizers`,
@@ -69,6 +69,58 @@ export const organizationFilterCompilers: SQLFilterDefinitions = {
69
69
  type: SQLValueType.JSONArray,
70
70
  nullable: false,
71
71
  }),
72
+ recordCategoryName: createColumnFilter({
73
+ expression: SQL.jsonExtract(SQL.column('organizations', 'meta'), '$.value.recordsConfiguration.recordCategories[*].name'),
74
+ type: SQLValueType.JSONArray,
75
+ nullable: true,
76
+ }),
77
+ // Name of a child (sub)category in any record category, at any nesting depth
78
+ recordChildCategoryName: createColumnFilter({
79
+ expression: SQL.jsonExtract(SQL.column('organizations', 'meta'), '$.value.recordsConfiguration.recordCategories**.childCategories[*].name'),
80
+ type: SQLValueType.JSONArray,
81
+ nullable: true,
82
+ }),
83
+ recordName: createColumnFilter({
84
+ expression: SQL.jsonExtract(SQL.column('organizations', 'meta'), '$.value.recordsConfiguration.recordCategories**.records[*].name'),
85
+ type: SQLValueType.JSONArray,
86
+ nullable: true,
87
+ }),
88
+ documentTemplates: createExistsFilter(
89
+ SQL.select()
90
+ .from(SQL.table('document_templates'))
91
+ .where(
92
+ SQL.column('document_templates', 'organizationId'),
93
+ SQL.column('organizations', 'id'),
94
+ ),
95
+ {
96
+ ...baseSQLFilterCompilers,
97
+ type: createColumnFilter({
98
+ expression: SQL.jsonExtract(SQL.column('document_templates', 'privateSettings'), '$.value.templateDefinition.type'),
99
+ type: SQLValueType.JSONString,
100
+ nullable: true,
101
+ }),
102
+ year: createColumnFilter({
103
+ expression: SQL.column('document_templates', 'year'),
104
+ type: SQLValueType.Number,
105
+ nullable: false,
106
+ }),
107
+ status: createColumnFilter({
108
+ expression: SQL.column('document_templates', 'status'),
109
+ type: SQLValueType.String,
110
+ nullable: false,
111
+ }),
112
+ isLocked: createColumnFilter({
113
+ expression: SQL.column('document_templates', 'isLocked'),
114
+ type: SQLValueType.Boolean,
115
+ nullable: false,
116
+ }),
117
+ updatesEnabled: createColumnFilter({
118
+ expression: SQL.column('document_templates', 'updatesEnabled'),
119
+ type: SQLValueType.Boolean,
120
+ nullable: false,
121
+ }),
122
+ },
123
+ ),
72
124
  setupSteps: createExistsFilter(
73
125
  SQL.select()
74
126
  .from(SQL.table('organization_registration_periods'))
@@ -301,13 +301,15 @@ describe('E2E.Documents', () => {
301
301
  await assertNoDocument(registration);
302
302
 
303
303
  // Change age as user (different endpoint, but should also have the same side effect)
304
+ // A user may only make a small birth date correction, so we only change the year (the
305
+ // member was born on 2000-01-01 above) to make them young again.
304
306
  await patchUserMember({
305
307
  organization,
306
308
  user,
307
309
  patch: MemberWithRegistrationsBlob.patch({
308
310
  id: member.id,
309
311
  details: MemberDetails.patch({
310
- birthDay: new Date(),
312
+ birthDay: new Date(new Date().getFullYear(), 0, 1),
311
313
  }),
312
314
  }),
313
315
  });
@@ -19,7 +19,7 @@ export async function setup() {
19
19
 
20
20
  const { Database } = await import('@simonbackx/simple-database');
21
21
 
22
- const { run } = await import('../dist/src/migrate.js');
22
+ const { run } = await import('../src/migrate.js');
23
23
  await run();
24
24
 
25
25
  await Database.end();