@stamhoofd/backend 2.122.4 → 2.122.6

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stamhoofd/backend",
3
- "version": "2.122.4",
3
+ "version": "2.122.6",
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.122.4",
61
- "@stamhoofd/backend-i18n": "2.122.4",
62
- "@stamhoofd/backend-middleware": "2.122.4",
63
- "@stamhoofd/crons": "2.122.4",
64
- "@stamhoofd/email": "2.122.4",
65
- "@stamhoofd/excel-writer": "2.122.4",
66
- "@stamhoofd/logging": "2.122.4",
67
- "@stamhoofd/models": "2.122.4",
68
- "@stamhoofd/object-differ": "2.122.4",
69
- "@stamhoofd/queues": "2.122.4",
70
- "@stamhoofd/sql": "2.122.4",
71
- "@stamhoofd/structures": "2.122.4",
72
- "@stamhoofd/types": "2.122.4",
73
- "@stamhoofd/utility": "2.122.4",
60
+ "@stamhoofd/backend-env": "2.122.6",
61
+ "@stamhoofd/backend-i18n": "2.122.6",
62
+ "@stamhoofd/backend-middleware": "2.122.6",
63
+ "@stamhoofd/crons": "2.122.6",
64
+ "@stamhoofd/email": "2.122.6",
65
+ "@stamhoofd/excel-writer": "2.122.6",
66
+ "@stamhoofd/logging": "2.122.6",
67
+ "@stamhoofd/models": "2.122.6",
68
+ "@stamhoofd/object-differ": "2.122.6",
69
+ "@stamhoofd/queues": "2.122.6",
70
+ "@stamhoofd/sql": "2.122.6",
71
+ "@stamhoofd/structures": "2.122.6",
72
+ "@stamhoofd/types": "2.122.6",
73
+ "@stamhoofd/utility": "2.122.6",
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.122.4",
94
+ "@stamhoofd/test-utils": "2.122.6",
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": "4258dcd68de9636eb141d48192d4e73da3c814df"
110
+ "gitHead": "7a9fd6be1074753301ef8e3dad8f79164522567f"
111
111
  }
@@ -201,6 +201,14 @@ export class AdminPermissionChecker {
201
201
  return p;
202
202
  }
203
203
 
204
+ getUnloadedOrganizationPermissions(organizationOrId: string | Organization) {
205
+ if (!this.user.permissions) {
206
+ return null;
207
+ }
208
+
209
+ return this.user.permissions.organizationPermissions.get(typeof organizationOrId === 'string' ? organizationOrId : organizationOrId.id) ?? null;
210
+ }
211
+
204
212
  async canAccessPrivateOrganizationData(organization: Organization) {
205
213
  if (!this.checkScope(organization.id)) {
206
214
  return false;
@@ -212,6 +220,24 @@ export class AdminPermissionChecker {
212
220
  return true;
213
221
  }
214
222
 
223
+ /**
224
+ * This is required to match the frontend, which expects privateMeta
225
+ * as soon as the user has unloaded permissions (it cannot load the permissions yet without the private meta).
226
+ *
227
+ * If the user expected private meta, but should not be allowed to have private meta (canAccessPrivateOrganizationData) (= meaning permissions resolve to nothing)
228
+ * then we return a stubbed private meta object
229
+ */
230
+ async doesFrontendExpectPrivateMeta(organization: Organization) {
231
+ if (!this.checkScope(organization.id)) {
232
+ return false;
233
+ }
234
+
235
+ if (!await this.hasSomeUnloadedAccess(organization)) {
236
+ return false;
237
+ }
238
+ return true;
239
+ }
240
+
215
241
  checkScope(organizationId: string | null) {
216
242
  if (organizationId) {
217
243
  // If request is scoped to a different organization
@@ -1096,6 +1122,14 @@ export class AdminPermissionChecker {
1096
1122
  return !!organizationPermissions && !organizationPermissions.isEmpty;
1097
1123
  }
1098
1124
 
1125
+ /**
1126
+ * Use this as a circuit breaker to avoid queries for non-admin users
1127
+ */
1128
+ async hasSomeUnloadedAccess(organizationOrId: string | Organization): Promise<boolean> {
1129
+ const unloadedPermissions = this.getUnloadedOrganizationPermissions(organizationOrId);
1130
+ return !!unloadedPermissions && !unloadedPermissions.isEmpty;
1131
+ }
1132
+
1099
1133
  async canManageAdmins(organizationId: string) {
1100
1134
  return !this.user.isApiUser && (await this.hasFullAccess(organizationId));
1101
1135
  }
@@ -1363,12 +1397,12 @@ export class AdminPermissionChecker {
1363
1397
  }
1364
1398
 
1365
1399
  private async loopRecordAnswerSettingsAccess<T extends RecordAnswer | AutoEncoderPatchType<RecordAnswer>>({ member, level, recordAnswers, callback }:
1366
- {
1367
- member: MemberWithUsersRegistrationsAndGroups;
1368
- level: PermissionLevel;
1369
- recordAnswers: PatchMap<string, T | null | undefined> | Map<string, T>;
1370
- callback: (result: { canAccess: false; record: RecordSettings | null } | { canAccess: true; record: RecordSettings }, entry: [keys: string, value: T | null | undefined]) => void;
1371
- }): Promise<void> {
1400
+ {
1401
+ member: MemberWithUsersRegistrationsAndGroups;
1402
+ level: PermissionLevel;
1403
+ recordAnswers: PatchMap<string, T | null | undefined> | Map<string, T>;
1404
+ callback: (result: { canAccess: false; record: RecordSettings | null } | { canAccess: true; record: RecordSettings }, entry: [keys: string, value: T | null | undefined]) => void;
1405
+ }): Promise<void> {
1372
1406
  if (STAMHOOFD.userMode !== 'platform') {
1373
1407
  let organization = this.organization;
1374
1408
 
@@ -2,7 +2,7 @@ import { SimpleError } from '@simonbackx/simple-errors';
2
2
  import type { AuditLog, Document, EventNotification, MemberWithUsersRegistrationsAndGroups, Order, Ticket } from '@stamhoofd/models';
3
3
  import { BalanceItem, CachedBalance, Event, Group, Invoice, Member, MemberPlatformMembership, MemberResponsibilityRecord, Organization, OrganizationRegistrationPeriod, Payment, Registration, RegistrationInvitation, RegistrationPeriod, User, Webshop } from '@stamhoofd/models';
4
4
  import type { PaymentGeneral } from '@stamhoofd/structures';
5
- import { BaseOrganization, getAppHost } from '@stamhoofd/structures';
5
+ import { BaseOrganization, getAppHost, OrganizationPrivateMetaData } from '@stamhoofd/structures';
6
6
  import { Payment as PaymentStruct, AuditLogReplacement, AuditLogReplacementType, AuditLog as AuditLogStruct, BalanceItem as BalanceItemStruct, DetailedReceivableBalance, Document as DocumentStruct, EventNotification as EventNotificationStruct, Event as EventStruct, GenericBalance, Group as GroupStruct, GroupType, InvitationGroupData, InvitationMemberData, InvoicedBalanceItem, InvoiceStruct, MemberPlatformMembership as MemberPlatformMembershipStruct, MemberRegistrationInvitation, MembersBlob, MemberWithRegistrationsBlob, NamedObject, OrganizationRegistrationPeriod as OrganizationRegistrationPeriodStruct, Organization as OrganizationStruct, PaymentCustomer, PermissionLevel, Platform, PrivateOrder, PrivateWebshop, ReceivableBalanceObject, ReceivableBalanceObjectContact, ReceivableBalance as ReceivableBalanceStruct, ReceivableBalanceType, RegistrationInvitation as RegistrationInvitationStruct, RegistrationsBlob, RegistrationWithMemberBlob, TicketPrivate, UserWithMembers, WebshopPreview, Webshop as WebshopStruct } from '@stamhoofd/structures';
7
7
  import { Sorter } from '@stamhoofd/utility';
8
8
 
@@ -265,7 +265,7 @@ export class AuthenticatedStructures {
265
265
  }
266
266
 
267
267
  // Get registration period and whether private data can be accessed for each organization
268
- const organizationData: Map<string, { organizationRegistrationPeriod: OrganizationRegistrationPeriodStruct; canAccessPrivateData: boolean }> = new Map();
268
+ const organizationData: Map<string, { organizationRegistrationPeriod: OrganizationRegistrationPeriodStruct; canAccessPrivateData: boolean; doesFrontendExpectPrivateMeta: boolean }> = new Map();
269
269
  const allPeriodIds = periodIdOrganizationsMap.keys();
270
270
  const allPeriods = await RegistrationPeriod.getByIDs(...allPeriodIds);
271
271
  const periodMap = new Map<string, RegistrationPeriod>(allPeriods.map(p => [p.id, p]));
@@ -306,10 +306,12 @@ export class AuthenticatedStructures {
306
306
 
307
307
  // check if private data can be accessed
308
308
  const canAccessPrivateData = await Context.optionalAuth?.canAccessPrivateOrganizationData(organization) ?? false;
309
+ const doesFrontendExpectPrivateMeta = canAccessPrivateData || (await Context.optionalAuth?.doesFrontendExpectPrivateMeta(organization) ?? false);
310
+
309
311
  if (canAccessPrivateData) {
310
312
  organizationIdsToGetWebshopsFor.push(organization.id);
311
313
  }
312
- organizationData.set(organizationId, { organizationRegistrationPeriod, canAccessPrivateData });
314
+ organizationData.set(organizationId, { organizationRegistrationPeriod, canAccessPrivateData, doesFrontendExpectPrivateMeta });
313
315
  } else {
314
316
  // Should never happen
315
317
  throw new Error('Organization registration period model not found for organization id ' + organizationId + ' and period id ' + periodId);
@@ -381,6 +383,7 @@ export class AuthenticatedStructures {
381
383
  result = OrganizationStruct.create({
382
384
  ...baseStruct,
383
385
  period,
386
+ privateMeta: data.doesFrontendExpectPrivateMeta ? OrganizationPrivateMetaData.create({}) : undefined,
384
387
  });
385
388
  }
386
389
 
@@ -3,13 +3,18 @@ import { Group } from '@stamhoofd/models';
3
3
  import { SeedTools } from '../helpers/SeedTools.js';
4
4
 
5
5
  export default new Migration(async () => {
6
- if (STAMHOOFD.environment == 'test') {
6
+ if (STAMHOOFD.environment === 'test') {
7
7
  console.log('skipped in tests');
8
8
  return;
9
9
  }
10
10
 
11
- if (STAMHOOFD.userMode !== 'platform') {
12
- console.log('skipped seed group-update-occupancy because usermode not platform');
11
+ if (STAMHOOFD.userMode === 'platform') {
12
+ console.log('skipped seed group-update-occupancy because usermode is platform');
13
+ return;
14
+ }
15
+
16
+ if (STAMHOOFD.platformName.toLowerCase() !== 'stamhoofd') {
17
+ console.log('skipped for platform (only runs for Stamhoofd): ' + STAMHOOFD.platformName);
13
18
  return;
14
19
  }
15
20
 
@@ -0,0 +1,85 @@
1
+ import { Migration } from '@simonbackx/simple-database';
2
+ import { Group, Organization, RegistrationPeriod } from '@stamhoofd/models';
3
+ import { GroupSettings, GroupType, TranslatedString, WaitingListType } 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
+ async function start(dryRun: boolean) {
26
+ await SeedTools.loop({
27
+ query: Organization.select(),
28
+ batchSize: 50,
29
+ useTransactionPerBatch: true,
30
+ action: async (organization: Organization) => {
31
+ const groups: Group[] = await Group.select()
32
+ .where('organizationId', organization.id)
33
+ // only for current period
34
+ .where('periodId', organization.periodId)
35
+ .where('type', GroupType.Membership)
36
+ .fetch();
37
+
38
+ if (groups.length === 0) {
39
+ return;
40
+ }
41
+
42
+ // const period = await OrganizationRegistrationPeriod.select().where('organizationId', organization.id).where('periodId', organization.periodId).first(true);
43
+ const period = await RegistrationPeriod.getByID(organization.periodId, true);
44
+
45
+ // handle group permissions
46
+ for (const group of groups) {
47
+ if (group.settings.waitingListType === WaitingListType.None) {
48
+ continue;
49
+ }
50
+
51
+ if (group.waitingListId) {
52
+ continue;
53
+ }
54
+
55
+ const newWaitingList = await createWaitingList(group, period, dryRun);
56
+ group.waitingListId = newWaitingList.id;
57
+
58
+ if (!dryRun) {
59
+ await group.save();
60
+ }
61
+ }
62
+ },
63
+
64
+ });
65
+ }
66
+
67
+ const cycleIfMigrated = -99;
68
+
69
+ async function createWaitingList(originalGroup: Group, period: RegistrationPeriod, dryRun: boolean) {
70
+ const newWaitingList = new Group();
71
+ newWaitingList.cycle = cycleIfMigrated;
72
+ newWaitingList.type = GroupType.WaitingList;
73
+ newWaitingList.organizationId = originalGroup.organizationId;
74
+ newWaitingList.periodId = period.id;
75
+ newWaitingList.settings = GroupSettings.create({
76
+ name: TranslatedString.create($t(`%yh`) + ' ' + originalGroup.settings.name.toString()),
77
+ period: period.getBaseStructure(),
78
+ });
79
+
80
+ if (!dryRun) {
81
+ await newWaitingList.save();
82
+ }
83
+
84
+ return newWaitingList;
85
+ }
@@ -0,0 +1,343 @@
1
+ import { Migration } from '@simonbackx/simple-database';
2
+ import { Group, Organization, OrganizationRegistrationPeriod, Registration, RegistrationInvitation, RegistrationPeriod, V1GroupMigrationData, V1WaitingListMigrationData } from '@stamhoofd/models';
3
+ import { GroupPrivateSettings, GroupSettings, GroupStatus, GroupType, 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
+ async function start(dryRun: boolean) {
26
+ const relevantOrganizations = new Set(['f2543d6b-523e-4b03-848b-838200b1ff41', '11a478dd-e8cf-4160-80dd-8b48d20a6d39', '6d1c32cf-2ef0-4738-bacb-d553488f97b9', '3e6f5cb9-e130-4dda-92c9-26682fa09509', '57815733-5750-492f-beb1-1efd6229803a', '7677e32d-763a-4033-bc74-30d13f27556d', '7ac75a0f-f365-4ddc-836c-66a3267f24f0', '9dced404-a92d-44c4-b301-ecd29b6ac4e0', '97928f8a-b8d1-44cc-918f-09d0ae896c3b', 'f0edb665-1ce0-442a-88e7-5a62ea78c5fc', '842ada22-82f3-4cef-a1da-ae5da20b208d', '33744473-9ad9-4d3e-a9fc-31cd1b6a2753', 'ed6e74f1-51a0-4e04-8195-e722b015eeb5', '10827cc5-5f29-46cf-b26e-5fd11a9342cc', '5f4d4a26-43c3-4f80-86ad-64d07d5a46a7', '74e2bcbf-0ff9-484b-988a-e11d147122c0', 'c69512bc-ea0c-427a-ab90-08c3dcf1c856', '16742d64-0b31-4ce7-9c1e-6194882045b9', '95d59803-c50e-4bc1-adc9-90ade32b7579', 'eb6ccffa-41c5-45df-b4e4-1eb5749bc0fe', '1a534005-2784-400c-b8fe-bde09c901259', '27e06924-49a6-468c-a16e-b5735ace1894', '7a86f3db-08d8-4752-a77d-eb85f2167942', 'f562c735-7bf4-4e2e-8c0f-6584e8e96a1d', 'e2afe517-cd35-4d9e-a561-125ad184a2ff', 'fd934e40-734c-442d-b338-c3ae288dd55d']);
27
+
28
+ const relevantGroups = new Set([
29
+ '020a313c-0e97-4068-84ba-783d91e768a4', '02420f62-1a07-4e2a-ae92-13c35320fb27', '0b6d8422-857f-46e4-a632-a4af322c9193', '12ba896a-e064-4697-a5df-69664c9bd8cb', '134e33f9-3560-4eca-977b-aaef01af5ed4', '1a9bb59a-c85a-4338-94c6-f82061f1ee0e', '1bf5519d-4645-4c51-8ae5-7a202bb74e9e', '1c86c401-c32b-4c6f-a2b5-fb15649efa74', '1ddc6fde-f512-43ac-8e15-2496601e900e', '20cab6ae-fb30-4de8-948e-60caf0979ff7', '22194f9d-fba2-4497-b7b0-de8ba6e884ff', '2384f112-d50d-4fcf-b6f2-45db73a8944c', '26693e8a-b73d-4f28-a41c-d281ba91fb3e', '287ff7b8-3319-40fb-ad25-26b139db3272', '2e473bf7-36f9-4276-8770-032db776d154', '3282e30c-a3db-4f00-b827-646fcc532c43', '3490cfd8-4c0b-487f-8f0d-c96c62950160', '3d68762b-8133-42e0-a642-7649864b09b3', '4a7d205b-e996-4841-b21c-1336642e670d', '52b0285c-4935-4577-bc7b-30d0b8804fc9', '579263b6-ceb1-4be0-a31e-9a4a904f0cae', '5870423f-6faf-492e-accb-4bd110365328', '60e24adb-5bb3-48de-9008-5b80c905a01a', '6535d9b4-f2ad-4f7e-b7e2-9e50ddf5eff6', '6a508234-08aa-4b0b-9647-7aeb9d80fa5a', '722f3bdf-d9df-4a78-a4c1-783c9af3d21b', '754bb111-caad-49b3-8dde-5bd0f3ad9104', '76aef892-19b5-4deb-9276-0c6b19b652fd', '78fa525b-e346-4b78-9e25-f9b1b5134b77', '7989ea9d-354d-4eff-9350-8eb8a6c4732b', '7aba3792-0138-4302-8550-2ccb456a37ad', '7c480cd5-8db3-4c03-8f0b-a88556df0234', '8113c2a2-e788-4f11-815f-4b019d7ca966', '82958c24-bc12-45f2-9d0d-3c8b902df052', '88c28670-cf97-42e2-ae9e-d836d349c2e6', '88d87c1a-749f-453c-a60b-e54a7001a9f0', '8983c1d5-e8aa-4628-a251-17220336fad5', '89ced76d-8c98-4f83-bffa-8b467289972d', '8abed111-cda2-4bb8-8d98-21291a85fb4f', '8bb9b1c6-2d40-4a69-bbd5-63ce152b1a91', '8f98e452-e174-4946-96ec-888cc3498f56', '8fdffd17-84fa-41f7-8b74-744578d4b9d9', '92ae627b-8aa4-4197-885e-603d4a1be8aa', '9852662c-7e9f-4c26-a6d3-6335b49b9b3b', 'a06ddcb5-dc9c-4bec-a0f8-40fd4ab20016', 'a25bbeae-86b9-4162-8570-d121845f26e9', 'a690c59b-72eb-40bb-b181-d9dc904f9ec1', 'a9b0a795-3b7e-40bb-b8ec-65280d149dba', 'ab9ddbb9-21bc-4a8e-be7f-036554b70db4', 'bea51786-4d47-4bcb-bf79-fc636f047cbb', 'c806a74d-f0ae-4473-86bd-facb92085eb1', 'c9064135-322e-4848-9de0-61011d067697', 'cb7e738f-bcb5-4bc5-9962-2608ea9c4094', 'cbc21e01-bb7e-4f1d-94c8-d4aac3b7247d', 'cf766627-3e32-45a1-8d62-865b6cc1d4ed', 'd0cf99ce-24e9-4912-9dc8-660b4ff57dcc', 'd2a536af-f23d-4c3e-8275-dc2e5116ab60', 'd715451c-6292-4d58-83f8-ffce115fca21', 'd76b57b8-10c4-43e4-94d1-bb66d1053bb4', 'dcffb511-131b-401c-a4b5-13810596bc42', 'df010c46-fc79-401c-a4a4-1fddb478acff', 'dfaf11b8-f835-4952-99e1-2b0279931b05', 'e0b8e823-4213-4919-b1ed-70395ad15df2', 'e9470495-097c-47b5-8e63-cd4f7370fa2d', 'e9fe89f8-780b-417f-a4ee-b48e0b0bb8ad', 'eda36826-ee68-414d-a773-cb41e1a1e2d6', 'edaa90fa-869b-4f3c-8b90-4ea41fe9a364', 'f163d78a-9539-43a3-aeb7-443559599342', 'f24e2ca9-3036-4839-9ff4-91bd4ecb5c91', 'f665642c-93e7-4886-bd6e-f94252d54e77', 'f7e5436c-855d-4bf9-b2de-94e9d0fd3052', 'f822b26a-715a-4154-b2a2-b11e38373fad', 'fe2e4ec5-cee4-4648-ae87-2f8dee7f42c8', 'ff3a7e68-feb7-4aca-983d-a8f8315851e7',
30
+ ]);
31
+
32
+ let total = 0;
33
+
34
+ await SeedTools.loop({
35
+ query: Organization.select(),
36
+ batchSize: 50,
37
+ useTransactionPerBatch: true,
38
+ action: async (organization: Organization) => {
39
+ if (!relevantOrganizations.has(organization.id)) {
40
+ return;
41
+ }
42
+
43
+ const groups: Group[] = await Group.select()
44
+ .where('organizationId', organization.id)
45
+ .where('periodId', organization.periodId)
46
+ .fetch();
47
+
48
+ for (const group of groups) {
49
+ if (!relevantGroups.has(group.id)) {
50
+ continue;
51
+ }
52
+
53
+ await fixRegistrations(organization, group, dryRun);
54
+ total += 1;
55
+ }
56
+ },
57
+
58
+ });
59
+
60
+ console.log('Fixed registrations for' + total + ' groups');
61
+ }
62
+
63
+ async function getCurrentCycle(group: Group) {
64
+ const migrationData = await V1GroupMigrationData.select().where('oldGroupId', group.id).andWhere('newGroupId', group.id).first(true);
65
+ return migrationData.oldCycle;
66
+ }
67
+
68
+ async function fixRegistrations(organization: Organization, group: Group, dryRun: boolean) {
69
+ const registrations = await Registration.select()
70
+ .where('groupId', group.id)
71
+ .fetch();
72
+
73
+ const currentCycle = await getCurrentCycle(group);
74
+
75
+ const cycles = new Set<number>();
76
+
77
+ for (const registration of registrations) {
78
+ cycles.add(registration.cycle);
79
+ }
80
+
81
+ if (cycles.size < 2) {
82
+ console.error('No multiple cycles found for group (not expected):', group.id);
83
+ return;
84
+ }
85
+
86
+ const sortedCycles = Array.from(cycles).filter(c => c !== currentCycle).sort((a, b) => b - a);
87
+
88
+ const cyclesToMigrate = sortedCycles;
89
+
90
+ const archivePeriod = await RegistrationPeriod.select().where('organizationId', group.organizationId).where('customName', 'Gearchiveerde periodes').first(true);
91
+ const archiveOrganizationPeriod = await OrganizationRegistrationPeriod.select().where('organizationId', organization.id).where('periodId', archivePeriod.id).first(true);
92
+
93
+ const startIndex = await getStartIndex(group);
94
+ let currentIndex: number = startIndex;
95
+
96
+ const allNewGroups: Group[] = [];
97
+
98
+ for (const cycle of cyclesToMigrate) {
99
+ const newGroup = createPreviousGroup({ originalGroup: group, period: archivePeriod, index: currentIndex });
100
+
101
+ currentIndex = currentIndex + 1;
102
+
103
+ const registrationCount = await Registration.select()
104
+ .where('groupId', group.id)
105
+ .andWhere('cycle', cycle)
106
+ .count();
107
+
108
+ // only create group if there are registrations
109
+ if (registrationCount > 0) {
110
+ if (!dryRun) {
111
+ const migrationData = new V1GroupMigrationData();
112
+ migrationData.oldGroupId = group.id;
113
+ migrationData.oldCycle = cycle;
114
+
115
+ await newGroup.save();
116
+ migrationData.newGroupId = newGroup.id;
117
+ await migrationData.save();
118
+ }
119
+
120
+ await migrateRegistrations({ organization, period: archivePeriod, originalGroup: group, newGroup, cycle }, dryRun);
121
+ allNewGroups.push(newGroup);
122
+ } else {
123
+ console.error('No registrations found for group (not expected):', group.id);
124
+ }
125
+
126
+ // not needed to migrate requirePreviousGroupIds (was never set for these groups, checked in database)
127
+ // only 2 groups where preventPreviousGroupIds was set -> fix them manually ('6a508234-08aa-4b0b-9647-7aeb9d80fa5a', 'dfaf11b8-f835-4952-99e1-2b0279931b05')
128
+ }
129
+
130
+ if (allNewGroups.length === 0) {
131
+ throw new Error('No new groups for group (unexpected): ' + group.id);
132
+ }
133
+
134
+ const parentCategory = await findCategory(group, archiveOrganizationPeriod);
135
+
136
+ for (const newGroup of allNewGroups) {
137
+ if (newGroup.id === undefined && dryRun) {
138
+ continue;
139
+ }
140
+
141
+ if (parentCategory) {
142
+ if (!parentCategory.groupIds.includes(newGroup.id)) {
143
+ parentCategory.groupIds.push(newGroup.id);
144
+ } else {
145
+ throw new Error('group already in category:' + newGroup.id);
146
+ }
147
+ }
148
+
149
+ await cleanupGroup(newGroup, dryRun);
150
+ }
151
+
152
+ if (!dryRun) {
153
+ await archiveOrganizationPeriod.save();
154
+ await group.updateOccupancy();
155
+ await group.save();
156
+ }
157
+ }
158
+
159
+ async function cleanupGroup(group: Group, dryRun: boolean) {
160
+ group.settings.cycleSettings = new Map();
161
+ group.settings.preventPreviousGroupIds = [];
162
+ group.settings.requirePreviousGroupIds = [];
163
+ group.cycle = cycleIfMigrated;
164
+ if (group.status === GroupStatus.Archived) {
165
+ group.status = GroupStatus.Closed;
166
+ }
167
+
168
+ if (!dryRun) {
169
+ await group.updateOccupancy();
170
+ await group.save();
171
+ }
172
+ }
173
+
174
+ async function findCategory(group: Group, archiveOrganizationPeriod: OrganizationRegistrationPeriod) {
175
+ const previousGroups = await V1GroupMigrationData.select().where('oldGroupId', group.id).andWhereNot('newGroupId', group.id).fetch();
176
+
177
+ for (const previousGroup of previousGroups) {
178
+ const previousGroupId = previousGroup.newGroupId;
179
+ if (previousGroup.oldGroupId !== group.id || previousGroup.newGroupId === group.id) {
180
+ throw new Error('group id mismatch');
181
+ }
182
+
183
+ const allCategories = archiveOrganizationPeriod.settings.categories;
184
+ const rootCategory = archiveOrganizationPeriod.settings.rootCategory;
185
+ if (!rootCategory) {
186
+ throw new Error('root category not found for archiveOrganizationPeriod: ' + archiveOrganizationPeriod.id);
187
+ }
188
+
189
+ const parentCategory = allCategories.find(c => c.groupIds.includes(previousGroupId));
190
+ if (parentCategory) {
191
+ return parentCategory;
192
+ }
193
+ }
194
+
195
+ return null;
196
+ }
197
+
198
+ async function getStartIndex(group: Group) {
199
+ return await V1GroupMigrationData.select().where('oldGroupId', group.id).andWhereNot('newGroupId', group.id).count();
200
+ }
201
+
202
+ const cycleIfMigrated = -99;
203
+
204
+ async function migrateRegistrations({ organization, period, originalGroup, newGroup, cycle }: { organization: Organization; period: RegistrationPeriod; originalGroup: Group; newGroup: Group; cycle: number }, dryRun: boolean) {
205
+ // what for waiting lists of archive groups (previous cycles)?
206
+ let waitingList: Group | null = null;
207
+
208
+ const getOrCreateWaitingList = async () => {
209
+ if (newGroup.waitingListId) {
210
+ if (waitingList !== null) {
211
+ return waitingList;
212
+ }
213
+ const fetchedWaitingList = await Group.getByID(newGroup.waitingListId);
214
+
215
+ if (!fetchedWaitingList) {
216
+ throw new Error(`Waiting list not found: (waitingListId: ${newGroup.waitingListId}, groupId: ${newGroup.id})`);
217
+ }
218
+ }
219
+
220
+ const newWaitingList = new Group();
221
+ newWaitingList.cycle = cycleIfMigrated;
222
+ newWaitingList.type = GroupType.WaitingList;
223
+ newWaitingList.organizationId = organization.id;
224
+ newWaitingList.periodId = period.id;
225
+ newWaitingList.settings = GroupSettings.create({
226
+ name: TranslatedString.create($t(`%yh`) + ' ' + newGroup.settings.name.toString()),
227
+ period: period.getBaseStructure(),
228
+ });
229
+
230
+ const migrationData = new V1WaitingListMigrationData();
231
+ migrationData.oldGroupId = originalGroup.id;
232
+ migrationData.oldCycle = cycle;
233
+
234
+ if (!dryRun) {
235
+ await newWaitingList.save();
236
+ migrationData.newGroupId = newWaitingList.id;
237
+ await migrationData.save();
238
+ }
239
+
240
+ waitingList = newWaitingList;
241
+ return newWaitingList;
242
+ };
243
+
244
+ const registrations = await Registration.select()
245
+ .where('groupId', originalGroup.id)
246
+ .andWhere('cycle', cycle)
247
+ .fetch();
248
+
249
+ for (const registration of registrations) {
250
+ let invitation: RegistrationInvitation | null = null;
251
+
252
+ if (registration.waitingList) {
253
+ const waitingList = await getOrCreateWaitingList();
254
+ if (newGroup.waitingListId !== waitingList.id) {
255
+ newGroup.waitingListId = waitingList.id;
256
+
257
+ if (!dryRun) {
258
+ await newGroup.save();
259
+ }
260
+ }
261
+
262
+ registration.groupId = waitingList.id;
263
+
264
+ // in V1 registeredAt is not set on waiting list registrations
265
+ registration.registeredAt = registration.createdAt;
266
+
267
+ if (registration.canRegister) {
268
+ // we should create an invitation
269
+ invitation = new RegistrationInvitation();
270
+ invitation.groupId = newGroup.id;
271
+ invitation.memberId = registration.memberId;
272
+ invitation.organizationId = organization.id;
273
+ invitation.createdAt = registration.createdAt;
274
+
275
+ // deprecated -> set to false
276
+ registration.canRegister = false;
277
+ }
278
+ } else {
279
+ registration.groupId = newGroup.id;
280
+ }
281
+
282
+ registration.periodId = period.id;
283
+ registration.cycle = cycle;
284
+
285
+ if (!dryRun) {
286
+ await registration.save();
287
+ if (invitation) {
288
+ try {
289
+ await invitation.save();
290
+ } catch (e) {
291
+ // do not throw if duplicate
292
+ if (e.code !== 'ER_DUP_ENTRY') {
293
+ throw e;
294
+ }
295
+ }
296
+ }
297
+ }
298
+ }
299
+ }
300
+
301
+ function createPreviousGroup({ originalGroup, period, index }: { originalGroup: Group; period: RegistrationPeriod; index: number }) {
302
+ const newGroup = new Group();
303
+ newGroup.organizationId = originalGroup.organizationId;
304
+ newGroup.periodId = period.id;
305
+ newGroup.status = originalGroup.status;
306
+ newGroup.createdAt = originalGroup.createdAt;
307
+ newGroup.deletedAt = originalGroup.deletedAt;
308
+
309
+ const originalPrivateSettings: GroupPrivateSettings = originalGroup.privateSettings;
310
+
311
+ // todo: should group ids in permissions get updated?
312
+ newGroup.privateSettings = GroupPrivateSettings.create({
313
+ ...originalPrivateSettings,
314
+ });
315
+
316
+ newGroup.cycle = cycleIfMigrated;
317
+
318
+ const originalSettings: GroupSettings = originalGroup.settings;
319
+
320
+ const periodStartDate: Date = period.startDate;
321
+ const periodEndDate: Date = period.endDate;
322
+
323
+ // todo: how to choose start and end dates?
324
+ const startDate = new Date(periodStartDate);
325
+ const endDate = new Date(periodEndDate);
326
+
327
+ const isPlural = index > 0;
328
+ const extraName = isPlural ? `${index + 1} periodes geleden` : `${index + 1} periode geleden`;
329
+
330
+ newGroup.settings = GroupSettings
331
+ .create({
332
+ ...originalSettings,
333
+ cycleSettings: new Map(),
334
+ name: new TranslatedString(`${originalSettings.name.toString()} (${extraName})`),
335
+ startDate,
336
+ endDate,
337
+ hasCustomDates: true,
338
+ period: period.getBaseStructure(),
339
+ });
340
+ newGroup.type = originalGroup.type;
341
+
342
+ return newGroup;
343
+ }
@@ -0,0 +1,67 @@
1
+ import { Migration } from '@simonbackx/simple-database';
2
+ import { Organization } from '@stamhoofd/models';
3
+ import { AccessRight, PermissionLevel, PermissionRoleDetailed, PermissionsResourceType, ResourcePermissions } 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
+ if (STAMHOOFD.userMode !== 'organization') {
18
+ return;
19
+ }
20
+
21
+ if (STAMHOOFD.environment !== 'production') {
22
+ return;
23
+ }
24
+
25
+ await start();
26
+ });
27
+
28
+ async function start() {
29
+ await SeedTools.loop({
30
+ query: Organization.select(),
31
+ batchSize: 50,
32
+ useTransactionPerBatch: true,
33
+ action: async (organization: Organization) => {
34
+ const base = PermissionRoleDetailed.create({
35
+ level: PermissionLevel.None,
36
+ accessRights: [],
37
+ });
38
+
39
+ if (organization.meta.packages.useMembers) {
40
+ base.accessRights.push(
41
+ AccessRight.MemberWriteFinancialData,
42
+ AccessRight.MemberManageNRN,
43
+ );
44
+ base.resources.set(PermissionsResourceType.RecordCategories, new Map([
45
+ ['', ResourcePermissions.create({
46
+ level: PermissionLevel.Full,
47
+ })],
48
+ ]));
49
+ }
50
+
51
+ // Senders
52
+ base.resources.set(PermissionsResourceType.Senders, new Map([
53
+ ['', ResourcePermissions.create({
54
+ level: PermissionLevel.None,
55
+ accessRights: [
56
+ AccessRight.SendMessages,
57
+ ],
58
+ })],
59
+ ]));
60
+
61
+ for (const role of organization.privateMeta.roles) {
62
+ role.add(base);
63
+ }
64
+ await organization.save();
65
+ },
66
+ });
67
+ }