@stamhoofd/backend 2.23.0 → 2.24.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.
@@ -12,6 +12,11 @@
12
12
  "BE": "www.be.stamhoofd",
13
13
  "NL": "www.nl.stamhoofd"
14
14
  },
15
+ "documentation": {
16
+ "": "www.be.stamhoofd/docs",
17
+ "BE": "www.be.stamhoofd/docs",
18
+ "NL": "www.nl.stamhoofd/docs"
19
+ },
15
20
  "webshop": {
16
21
  "": "shop.be.stamhoofd",
17
22
  "BE": "shop.be.stamhoofd",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stamhoofd/backend",
3
- "version": "2.23.0",
3
+ "version": "2.24.0",
4
4
  "main": "./dist/index.js",
5
5
  "exports": {
6
6
  ".": {
@@ -36,14 +36,14 @@
36
36
  "@simonbackx/simple-encoding": "2.15.0",
37
37
  "@simonbackx/simple-endpoints": "1.14.0",
38
38
  "@simonbackx/simple-logging": "^1.0.1",
39
- "@stamhoofd/backend-i18n": "2.23.0",
40
- "@stamhoofd/backend-middleware": "2.23.0",
41
- "@stamhoofd/email": "2.23.0",
42
- "@stamhoofd/models": "2.23.0",
43
- "@stamhoofd/queues": "2.23.0",
44
- "@stamhoofd/sql": "2.23.0",
45
- "@stamhoofd/structures": "2.23.0",
46
- "@stamhoofd/utility": "2.23.0",
39
+ "@stamhoofd/backend-i18n": "2.24.0",
40
+ "@stamhoofd/backend-middleware": "2.24.0",
41
+ "@stamhoofd/email": "2.24.0",
42
+ "@stamhoofd/models": "2.24.0",
43
+ "@stamhoofd/queues": "2.24.0",
44
+ "@stamhoofd/sql": "2.24.0",
45
+ "@stamhoofd/structures": "2.24.0",
46
+ "@stamhoofd/utility": "2.24.0",
47
47
  "archiver": "^7.0.1",
48
48
  "aws-sdk": "^2.885.0",
49
49
  "axios": "1.6.8",
@@ -60,5 +60,5 @@
60
60
  "postmark": "4.0.2",
61
61
  "stripe": "^16.6.0"
62
62
  },
63
- "gitHead": "efcdf16aa2160bcb1fd295d0e5c72751caa3356b"
63
+ "gitHead": "bad7d2adfa412af0b0de101274e46ed0b539ae38"
64
64
  }
@@ -64,7 +64,7 @@ export class SignupEndpoint extends Endpoint<Params, Query, Body, ResponseBody>
64
64
  replyTo: undefined
65
65
  }
66
66
 
67
- const footer = (!user.permissions && organization ? "\n\n—\n\nOnze ledenadministratie werkt via het Stamhoofd platform, op maat van verenigingen. Probeer het ook via https://"+request.i18n.$t("shared.domains.marketing")+"/ledenadministratie\n\n" : '')
67
+ const footer = (!user.permissions && organization ? "\n\n—\n\nOnze ledenadministratie werkt via het Stamhoofd platform, op maat van verenigingen. Probeer het ook via https://"+request.i18n.localizedDomains.marketing()+"/ledenadministratie\n\n" : '')
68
68
 
69
69
  const name = organization ? organization.name : 'Stamhoofd'
70
70
  // Send email
@@ -3,7 +3,7 @@ import { ConvertArrayToPatchableArray, Decoder, PatchableArrayAutoEncoder, Patch
3
3
  import { DecodedRequest, Endpoint, Request, Response } from "@simonbackx/simple-endpoints";
4
4
  import { SimpleError } from "@simonbackx/simple-errors";
5
5
  import { BalanceItem, Document, Group, Member, MemberFactory, MemberPlatformMembership, MemberResponsibilityRecord, MemberWithRegistrations, Organization, Platform, Registration, User } from '@stamhoofd/models';
6
- import { MemberWithRegistrationsBlob, MembersBlob, PermissionLevel } from "@stamhoofd/structures";
6
+ import { GroupType, MemberWithRegistrationsBlob, MembersBlob, PermissionLevel } from "@stamhoofd/structures";
7
7
  import { Formatter } from '@stamhoofd/utility';
8
8
 
9
9
  import { AuthenticatedStructures } from '../../../helpers/AuthenticatedStructures';
@@ -267,6 +267,33 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
267
267
  })
268
268
  }
269
269
 
270
+ const hasRegistration = member.registrations.some(registration => {
271
+ if (platformResponsibility) {
272
+ if (registration.group.defaultAgeGroupId === null) {
273
+ return false;
274
+ }
275
+ }
276
+
277
+ if (org) {
278
+ if (registration.periodId !== org.periodId) {
279
+ return false;
280
+ }
281
+ } else {
282
+ if (registration.periodId !== platform.periodId) {
283
+ return false;
284
+ }
285
+ }
286
+ return registration.deactivatedAt === null && registration.registeredAt !== null && registration.group.type === GroupType.Membership
287
+ })
288
+
289
+ if (!hasRegistration) {
290
+ throw new SimpleError({
291
+ code: "invalid_field",
292
+ message: "Invalid organization",
293
+ human: "Je kan een functie enkel toekennen aan leden die zijn ingeschreven in het huidige werkjaar",
294
+ })
295
+ }
296
+
270
297
  const model = new MemberResponsibilityRecord()
271
298
  model.memberId = member.id
272
299
  model.responsibilityId = responsibility.id
@@ -6,6 +6,7 @@ import { MemberResponsibility, PlatformConfig, PlatformPremiseType, Platform as
6
6
  import { SimpleError } from "@simonbackx/simple-errors";
7
7
  import { Context } from "../../../helpers/Context";
8
8
  import { SetupStepUpdater } from "../../../helpers/SetupStepsUpdater";
9
+ import { PeriodHelper } from "../../../helpers/PeriodHelper";
9
10
 
10
11
  type Params = Record<string, never>;
11
12
  type Query = undefined;
@@ -73,6 +74,7 @@ export class PatchPlatformEndpoint extends Endpoint<
73
74
  }
74
75
 
75
76
  let shouldUpdateSetupSteps = false;
77
+ let shouldMoveToPeriod: RegistrationPeriod | null = null;
76
78
 
77
79
  if (request.body.config) {
78
80
  if (!Context.auth.hasPlatformFullAccess()) {
@@ -113,6 +115,8 @@ export class PatchPlatformEndpoint extends Endpoint<
113
115
  });
114
116
  }
115
117
  platform.periodId = period.id;
118
+ shouldUpdateSetupSteps = true;
119
+ shouldMoveToPeriod = period;
116
120
  }
117
121
 
118
122
  if (request.body.membershipOrganizationId !== undefined) {
@@ -155,7 +159,10 @@ export class PatchPlatformEndpoint extends Endpoint<
155
159
 
156
160
  await platform.save();
157
161
 
158
- if(shouldUpdateSetupSteps) {
162
+ if (shouldMoveToPeriod) {
163
+ PeriodHelper.moveAllOrganizationsToPeriod(shouldMoveToPeriod).catch(console.error)
164
+ } else if(shouldUpdateSetupSteps) {
165
+ // Do not call this right away when moving to a period, because this needs to happen AFTER moving to the period
159
166
  SetupStepUpdater.updateSetupStepsForAllOrganizationsInCurrentPeriod().catch(console.error);
160
167
  }
161
168
 
@@ -1,11 +1,11 @@
1
1
  import { DecodedRequest, Endpoint, Request, Response } from "@simonbackx/simple-endpoints";
2
- import { Group as GroupStruct, GroupPrivateSettings, OrganizationRegistrationPeriod as OrganizationRegistrationPeriodStruct, PermissionLevel, PermissionsResourceType, ResourcePermissions, Version, GroupType } from "@stamhoofd/structures";
2
+ import { GroupPrivateSettings, Group as GroupStruct, GroupType, OrganizationRegistrationPeriod as OrganizationRegistrationPeriodStruct, PermissionLevel, PermissionsResourceType, ResourcePermissions, Version } from "@stamhoofd/structures";
3
3
 
4
4
  import { AutoEncoderPatchType, Decoder, PatchableArrayAutoEncoder, PatchableArrayDecoder, StringDecoder } from "@simonbackx/simple-encoding";
5
- import { Context } from "../../../../helpers/Context";
6
- import { Group, Member, OrganizationRegistrationPeriod, Platform, RegistrationPeriod } from "@stamhoofd/models";
7
5
  import { SimpleError } from "@simonbackx/simple-errors";
6
+ import { Group, Member, Organization, OrganizationRegistrationPeriod, Platform, RegistrationPeriod } from "@stamhoofd/models";
8
7
  import { AuthenticatedStructures } from "../../../../helpers/AuthenticatedStructures";
8
+ import { Context } from "../../../../helpers/Context";
9
9
 
10
10
  type Params = Record<string, never>;
11
11
  type Query = undefined;
@@ -46,9 +46,12 @@ export class PatchOrganizationRegistrationPeriodsEndpoint extends Endpoint<Param
46
46
  if (!await Context.auth.hasFullAccess(organization.id)) {
47
47
  throw Context.auth.error()
48
48
  }
49
- const period = await RegistrationPeriod.getByID(put.period.id);
49
+ periods.push(await PatchOrganizationRegistrationPeriodsEndpoint.createOrganizationPeriod(organization, put));
50
+ }
50
51
 
51
- if (!period) {
52
+ for (const patch of request.body.getPatches()) {
53
+ const organizationPeriod = await OrganizationRegistrationPeriod.getByID(patch.id);
54
+ if (!organizationPeriod || organizationPeriod.organizationId !== organization.id) {
52
55
  throw new SimpleError({
53
56
  code: "not_found",
54
57
  message: "Period not found",
@@ -56,38 +59,33 @@ export class PatchOrganizationRegistrationPeriodsEndpoint extends Endpoint<Param
56
59
  })
57
60
  }
58
61
 
59
- const organizationPeriod = new OrganizationRegistrationPeriod();
60
- organizationPeriod.id = put.id;
61
- organizationPeriod.organizationId = organization.id;
62
- organizationPeriod.periodId = put.period.id;
63
- organizationPeriod.settings = put.settings;
64
- await organizationPeriod.save();
62
+ const period = await RegistrationPeriod.getByID(organizationPeriod.periodId);
65
63
 
66
- for (const struct of put.groups) {
67
- await PatchOrganizationRegistrationPeriodsEndpoint.createGroup(struct, organization.id, organizationPeriod.periodId)
64
+ if (!period) {
65
+ throw new SimpleError({
66
+ code: "not_found",
67
+ message: "Period not found",
68
+ statusCode: 404
69
+ })
68
70
  }
69
- const groups = await Group.getAll(organization.id, organizationPeriod.periodId)
70
-
71
- // Delete unreachable categories first
72
- await organizationPeriod.cleanCategories(groups);
73
- await Group.deleteUnreachable(organization.id, organizationPeriod, groups)
74
- periods.push(organizationPeriod);
75
- }
76
71
 
77
- for (const patch of request.body.getPatches()) {
78
- const organizationPeriod = await OrganizationRegistrationPeriod.getByID(patch.id);
79
- if (!organizationPeriod || organizationPeriod.organizationId !== organization.id) {
72
+ if (period.locked) {
80
73
  throw new SimpleError({
81
74
  code: "not_found",
82
75
  message: "Period not found",
76
+ human: 'Je kan geen wijzigingen meer aanbrengen in ' + period.getStructure().name + ' omdat deze is afgesloten',
83
77
  statusCode: 404
84
78
  })
85
79
  }
80
+
86
81
  let deleteUnreachable = false
87
82
  const allowedIds: string[] = []
88
83
 
89
84
  if (await Context.auth.hasFullAccess(organization.id)) {
90
85
  if (patch.settings) {
86
+ if(patch.settings.categories) {
87
+ deleteUnreachable = true;
88
+ }
91
89
  organizationPeriod.settings.patchOrPut(patch.settings);
92
90
  }
93
91
  } else {
@@ -174,6 +172,36 @@ export class PatchOrganizationRegistrationPeriodsEndpoint extends Endpoint<Param
174
172
  })
175
173
  }
176
174
 
175
+ static async createOrganizationPeriod(organization: Organization, struct: OrganizationRegistrationPeriodStruct) {
176
+ const period = await RegistrationPeriod.getByID(struct.period.id);
177
+
178
+ if (!period || period.locked) {
179
+ throw new SimpleError({
180
+ code: "not_found",
181
+ message: "Period not found",
182
+ statusCode: 404
183
+ })
184
+ }
185
+
186
+ const organizationPeriod = new OrganizationRegistrationPeriod();
187
+ organizationPeriod.id = struct.id;
188
+ organizationPeriod.organizationId = organization.id;
189
+ organizationPeriod.periodId = struct.period.id;
190
+ organizationPeriod.settings = struct.settings;
191
+ await organizationPeriod.save();
192
+
193
+ for (const s of struct.groups) {
194
+ await PatchOrganizationRegistrationPeriodsEndpoint.createGroup(s, organization.id, organizationPeriod.periodId)
195
+ }
196
+ const groups = await Group.getAll(organization.id, organizationPeriod.periodId)
197
+
198
+ // Delete unreachable categories first
199
+ await organizationPeriod.cleanCategories(groups);
200
+ await Group.deleteUnreachable(organization.id, organizationPeriod, groups)
201
+
202
+ return organizationPeriod
203
+ }
204
+
177
205
  static async deleteGroup(id: string) {
178
206
  const model = await Group.getByID(id)
179
207
  if (!model || !await Context.auth.canAccessGroup(model, PermissionLevel.Full)) {
@@ -263,6 +291,15 @@ export class PatchOrganizationRegistrationPeriodsEndpoint extends Endpoint<Param
263
291
  })
264
292
  }
265
293
 
294
+ if (existing.periodId !== model.periodId) {
295
+ throw new SimpleError({
296
+ code: 'invalid_field',
297
+ field: 'waitingList',
298
+ message: 'Waiting list group is already used in another period',
299
+ human: 'Een wachtlijst kan momenteel niet gedeeld worden tussen verschillende werkjaren'
300
+ })
301
+ }
302
+
266
303
  model.waitingListId = existing.id
267
304
  } else {
268
305
  const group = await PatchOrganizationRegistrationPeriodsEndpoint.createGroup(
@@ -262,14 +262,43 @@ export class AuthenticatedStructures {
262
262
  }
263
263
  }
264
264
 
265
+ const organizationStructs = await Promise.all([...organizations.values()].filter(o => o.active).map(o => this.organization(o)))
266
+
267
+ // Load missing groups
268
+ const allGroups = new Map<string, GroupStruct>()
269
+ for (const organization of organizationStructs) {
270
+ for (const group of organization.period.groups) {
271
+ allGroups.set(group.id, group)
272
+ }
273
+ }
274
+
265
275
  for (const blob of memberBlobs) {
266
- blob.responsibilities = responsibilities.filter(r => r.memberId == blob.id).map(r => r.getStructure())
276
+ for (const registration of blob.registrations) {
277
+ if (registration.group) {
278
+ allGroups.set(registration.group.id, registration.group)
279
+ }
280
+ }
281
+ }
282
+
283
+ const groupIds = Formatter.uniqueArray(responsibilities.map(r => r.groupId).filter(id => id !== null)).filter(id => !allGroups.has(id))
284
+ const groups = groupIds.length > 0 ? await Group.getByIDs(...groupIds) : []
285
+ const groupStructs = await this.groups(groups)
286
+
287
+ for (const group of groupStructs) {
288
+ allGroups.set(group.id, group)
289
+ }
290
+
291
+ for (const blob of memberBlobs) {
292
+ blob.responsibilities = responsibilities.filter(r => r.memberId == blob.id).map(r => {
293
+ const group = allGroups.get(r.groupId ?? '') ?? null
294
+ return r.getStructure(group)
295
+ })
267
296
  blob.platformMemberships = platformMemberships.filter(r => r.memberId == blob.id).map(r => MemberPlatformMembershipStruct.create(r))
268
297
  }
269
298
 
270
299
  return MembersBlob.create({
271
300
  members: memberBlobs,
272
- organizations: await Promise.all([...organizations.values()].filter(o => o.active).map(o => this.organization(o)))
301
+ organizations: organizationStructs
273
302
  })
274
303
  }
275
304
 
@@ -116,14 +116,14 @@ export class MemberUserSyncerStatic {
116
116
  if (organizationId === null) {
117
117
  const patch = user.permissions.convertPlatformPatch(
118
118
  Permissions.patch({
119
- responsibilities: (responsibilitiesByOrganization.get(organizationId) ?? []).map(r => r.getStructure()) as any
119
+ responsibilities: (responsibilitiesByOrganization.get(organizationId) ?? []).map(r => r.getBaseStructure()) as any
120
120
  })
121
121
  )
122
122
  user.permissions = user.permissions.patch(patch)
123
123
  } else {
124
124
  const patch = user.permissions.convertPatch(
125
125
  Permissions.patch({
126
- responsibilities: (responsibilitiesByOrganization.get(organizationId) ?? []).map(r => r.getStructure()) as any
126
+ responsibilities: (responsibilitiesByOrganization.get(organizationId) ?? []).map(r => r.getBaseStructure()) as any
127
127
  }),
128
128
  organizationId
129
129
  )
@@ -0,0 +1,70 @@
1
+
2
+ import { Organization, OrganizationRegistrationPeriod, RegistrationPeriod } from "@stamhoofd/models";
3
+ import { AuthenticatedStructures } from "./AuthenticatedStructures";
4
+ import { PatchOrganizationRegistrationPeriodsEndpoint } from "../endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint";
5
+ import { QueueHandler } from "@stamhoofd/queues";
6
+ import { SetupStepUpdater } from "./SetupStepsUpdater";
7
+
8
+ export class PeriodHelper {
9
+ static async moveOrganizationToPeriod(organization: Organization, period: RegistrationPeriod) {
10
+ console.log('moveOrganizationToPeriod', organization.id, period.id)
11
+
12
+ await this.createOrganizationPeriodForPeriod(organization, period)
13
+ organization.periodId = period.id
14
+ await organization.save()
15
+ }
16
+
17
+ static async createOrganizationPeriodForPeriod(organization: Organization, period: RegistrationPeriod) {
18
+ const oPeriods = await OrganizationRegistrationPeriod.where({ periodId: period.id, organizationId: organization.id }, {limit: 1})
19
+
20
+ if (oPeriods.length) {
21
+ // Already created
22
+ return oPeriods[0]
23
+ }
24
+
25
+ const currentPeriod = await organization.getPeriod()
26
+ if (currentPeriod.periodId === period.id) {
27
+ return currentPeriod
28
+ }
29
+
30
+ const struct = await AuthenticatedStructures.organizationRegistrationPeriod(currentPeriod)
31
+
32
+ const duplicate = struct.duplicate(period.getStructure())
33
+ return await PatchOrganizationRegistrationPeriodsEndpoint.createOrganizationPeriod(organization, duplicate)
34
+ }
35
+
36
+ static async moveAllOrganizationsToPeriod(period: RegistrationPeriod) {
37
+ const tag = "moveAllOrganizationsToPeriod";
38
+ const batchSize = 10;
39
+ QueueHandler.cancel(tag);
40
+
41
+ await QueueHandler.schedule(tag, async () => {
42
+ let lastId = "";
43
+
44
+ while (true) {
45
+ const organizations = await Organization.where(
46
+ {
47
+ id: { sign: ">", value: lastId },
48
+ },
49
+ {
50
+ limit: batchSize,
51
+ sort: ["id"]
52
+ }
53
+ );
54
+
55
+ for (const organization of organizations) {
56
+ await this.moveOrganizationToPeriod(organization, period);
57
+ lastId = organization.id;
58
+ }
59
+
60
+ if (organizations.length < batchSize) {
61
+ break;
62
+ }
63
+
64
+ }
65
+ });
66
+
67
+ // When done: update setup steps
68
+ await SetupStepUpdater.updateSetupStepsForAllOrganizationsInCurrentPeriod()
69
+ }
70
+ }
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  Group,
3
+ Member,
3
4
  MemberResponsibilityRecord,
4
5
  Organization,
5
6
  OrganizationRegistrationPeriod,
@@ -8,11 +9,13 @@ import {
8
9
  import { QueueHandler } from "@stamhoofd/queues";
9
10
  import { SQL, SQLWhereSign } from "@stamhoofd/sql";
10
11
  import {
12
+ GroupType,
11
13
  MemberResponsibility,
12
14
  Platform as PlatformStruct,
13
15
  SetupStepType,
14
16
  SetupSteps
15
17
  } from "@stamhoofd/structures";
18
+ import { Formatter } from "@stamhoofd/utility";
16
19
 
17
20
  type SetupStepOperation = (setupSteps: SetupSteps, organization: Organization, platform: PlatformStruct) => void | Promise<void>;
18
21
 
@@ -244,12 +247,21 @@ export class SetupStepUpdater {
244
247
 
245
248
  const responsibilityIds = organizationBasedResponsibilitiesWithRestriction.map(r => r.id);
246
249
 
247
- const records = await MemberResponsibilityRecord.select()
250
+ const allRecords = await MemberResponsibilityRecord.select()
248
251
  .where('responsibilityId', responsibilityIds)
249
252
  .where('organizationId', organization.id)
250
253
  .where(SQL.where('endDate', SQLWhereSign.Greater, now).or('endDate', null))
251
254
  .fetch();
252
255
 
256
+ // Remove invalid responsibilities: members that are not registered in the current period
257
+ const memberIds = Formatter.uniqueArray(allRecords.map(r => r.memberId));
258
+ const members = await Member.getBlobByIds(...memberIds);
259
+ const validMembers = members.filter(m => m.registrations.some(r => r.organizationId === organization.id && r.periodId === organization.periodId && r.group.type === GroupType.Membership && r.deactivatedAt === null && r.registeredAt !== null));
260
+
261
+ const validMembersIds = validMembers.map(m => m.id);
262
+
263
+ const records = allRecords.filter(r => validMembersIds.includes(r.memberId));
264
+
253
265
  let totalSteps = 0;
254
266
  let finishedSteps = 0;
255
267
 
@@ -279,11 +291,11 @@ export class SetupStepUpdater {
279
291
  for(const {responsibility, group} of flatResponsibilities) {
280
292
  const { minimumMembers: min, maximumMembers: max } = responsibility;
281
293
 
282
- if (min === null && max === null) {
294
+ if (min === null) {
283
295
  continue;
284
296
  }
285
297
 
286
- totalSteps++;
298
+ totalSteps += min;
287
299
 
288
300
  const responsibilityId = responsibility.id;
289
301
  let totalRecordsWithThisResponsibility = 0;
@@ -303,14 +315,11 @@ export class SetupStepUpdater {
303
315
  }
304
316
 
305
317
  if (max !== null && totalRecordsWithThisResponsibility > max) {
318
+ // Not added
306
319
  continue;
307
320
  }
308
321
 
309
- if (min !== null && totalRecordsWithThisResponsibility < min) {
310
- continue;
311
- }
312
-
313
- finishedSteps++;
322
+ finishedSteps += Math.min(min, totalRecordsWithThisResponsibility);
314
323
  }
315
324
 
316
325
  setupSteps.update(SetupStepType.Responsibilities, {