@stamhoofd/backend 2.117.0 → 2.118.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stamhoofd/backend",
3
- "version": "2.117.0",
3
+ "version": "2.118.0",
4
4
  "main": "./dist/index.js",
5
5
  "exports": {
6
6
  ".": {
@@ -55,14 +55,14 @@
55
55
  "@simonbackx/simple-encoding": "2.23.1",
56
56
  "@simonbackx/simple-endpoints": "1.20.1",
57
57
  "@simonbackx/simple-logging": "^1.0.1",
58
- "@stamhoofd/backend-i18n": "2.117.0",
59
- "@stamhoofd/backend-middleware": "2.117.0",
60
- "@stamhoofd/email": "2.117.0",
61
- "@stamhoofd/models": "2.117.0",
62
- "@stamhoofd/queues": "2.117.0",
63
- "@stamhoofd/sql": "2.117.0",
64
- "@stamhoofd/structures": "2.117.0",
65
- "@stamhoofd/utility": "2.117.0",
58
+ "@stamhoofd/backend-i18n": "2.118.0",
59
+ "@stamhoofd/backend-middleware": "2.118.0",
60
+ "@stamhoofd/email": "2.118.0",
61
+ "@stamhoofd/models": "2.118.0",
62
+ "@stamhoofd/queues": "2.118.0",
63
+ "@stamhoofd/sql": "2.118.0",
64
+ "@stamhoofd/structures": "2.118.0",
65
+ "@stamhoofd/utility": "2.118.0",
66
66
  "archiver": "^7.0.1",
67
67
  "axios": "^1.13.2",
68
68
  "cookie": "^0.7.0",
@@ -80,5 +80,5 @@
80
80
  "publishConfig": {
81
81
  "access": "public"
82
82
  },
83
- "gitHead": "ad4b006dca3aa5a7c14b2afc83b6df3c2a72424c"
83
+ "gitHead": "57fe442b1afa0bb2645295bed467c303b39a2573"
84
84
  }
@@ -1,6 +1,6 @@
1
1
  import { AutoEncoderPatchType, Decoder, PatchableArrayAutoEncoder, PatchableArrayDecoder, patchObject, StringDecoder } from '@simonbackx/simple-encoding';
2
2
  import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
3
- import { Event, Group, Platform, RegistrationPeriod, Webshop } from '@stamhoofd/models';
3
+ import { Event, Group, OrganizationRegistrationPeriod, Platform, RegistrationPeriod, Webshop } from '@stamhoofd/models';
4
4
  import { AuditLogSource, Event as EventStruct, Group as GroupStruct, GroupType, NamedObject, PermissionLevel } from '@stamhoofd/structures';
5
5
 
6
6
  import { SimpleError } from '@simonbackx/simple-errors';
@@ -134,26 +134,18 @@ export class PatchEventsEndpoint extends Endpoint<Params, Query, Body, ResponseB
134
134
  event.name = put.name;
135
135
  event.startDate = put.startDate;
136
136
  event.endDate = put.endDate;
137
+ event.typeId = put.typeId;
137
138
 
138
- const type = await PatchEventsEndpoint.getEventType(put.typeId);
139
- event.typeId = type.id;
140
139
  event.meta.organizationCache = eventOrganization ? NamedObject.create({ id: eventOrganization.id, name: eventOrganization.name }) : null;
141
140
  await PatchEventsEndpoint.checkEventLimits(event);
142
141
 
143
142
  if (put.group) {
144
- const group = await this.putEventGroup(event, put.group);
145
- await AuditLogService.setContext({ source: AuditLogSource.System }, async () => {
146
- await event.syncGroupRequirements(group);
147
- });
148
- event.groupId = group.id;
143
+ await this.saveEventAndLinkExistingGroup(event, put.group);
149
144
  }
150
-
151
- if (type.isLocationRequired === true) {
152
- PatchEventsEndpoint.throwIfAddressIsMissing(event);
145
+ else {
146
+ await event.save();
153
147
  }
154
148
 
155
- await event.save();
156
-
157
149
  events.push(event);
158
150
  }
159
151
 
@@ -228,10 +220,8 @@ export class PatchEventsEndpoint extends Endpoint<Params, Query, Body, ResponseB
228
220
  event.startDate = patch.startDate ?? event.startDate;
229
221
  event.endDate = patch.endDate ?? event.endDate;
230
222
 
231
- const type = await PatchEventsEndpoint.getEventType(patch.typeId ?? event.typeId);
232
-
233
223
  if (patch.typeId) {
234
- event.typeId = type.id;
224
+ event.typeId = patch.typeId;
235
225
  }
236
226
 
237
227
  await PatchEventsEndpoint.checkEventLimits(event);
@@ -317,10 +307,6 @@ export class PatchEventsEndpoint extends Endpoint<Params, Query, Body, ResponseB
317
307
  }
318
308
  }
319
309
 
320
- if (type.isLocationRequired === true) {
321
- PatchEventsEndpoint.throwIfAddressIsMissing(event);
322
- }
323
-
324
310
  if (patch.webshopId !== undefined) {
325
311
  if (patch.webshopId === null) {
326
312
  event.webshopId = null;
@@ -399,6 +385,21 @@ export class PatchEventsEndpoint extends Endpoint<Params, Query, Body, ResponseB
399
385
  static async checkEventLimits(event: Event) {
400
386
  const type = await this.getEventType(event.typeId);
401
387
 
388
+ if (type.isLocationRequired) {
389
+ const address = event.meta.location?.address;
390
+
391
+ if (!address) {
392
+ throw new SimpleError({
393
+ code: 'invalid_field',
394
+ message: 'Empty number',
395
+ human: $t(`6b72f8bd-cd5b-423f-a556-be102d3c22e9`),
396
+ field: 'event_required',
397
+ });
398
+ }
399
+
400
+ address.throwIfIncomplete();
401
+ }
402
+
402
403
  if (event.name.length < 2) {
403
404
  throw new SimpleError({
404
405
  code: 'invalid_field',
@@ -484,18 +485,85 @@ export class PatchEventsEndpoint extends Endpoint<Params, Query, Body, ResponseB
484
485
  }
485
486
  }
486
487
 
487
- private static throwIfAddressIsMissing(event: Event) {
488
- const address = event.meta.location?.address;
488
+ private async saveEventAndLinkExistingGroup(event: Event, putGroup: GroupStruct): Promise<void> {
489
+ const existingGroup = await Group.getByID(putGroup.id);
489
490
 
490
- if (!address) {
491
+ if (!existingGroup) {
491
492
  throw new SimpleError({
492
- code: 'invalid_field',
493
- message: 'Empty number',
494
- human: $t(`6b72f8bd-cd5b-423f-a556-be102d3c22e9`),
495
- field: 'event_required',
493
+ code: 'group_not_found',
494
+ message: `Group with id ${putGroup.id} does not exist`,
495
+ });
496
+ }
497
+
498
+ // keep track of original data for reset in case of failure
499
+ const originalGroupType = existingGroup.type;
500
+
501
+ if (!await Context.auth.canAccessGroup(existingGroup)) {
502
+ throw Context.auth.error($t(`Je hebt geen toegangsrechten om de groep te wijzigen.`));
503
+ }
504
+
505
+ if (event.organizationId !== existingGroup.organizationId) {
506
+ throw new SimpleError({
507
+ code: 'invalid_group',
508
+ message: 'Group has different organization id',
509
+ });
510
+ }
511
+
512
+ if (existingGroup.settings.eventId !== null && existingGroup.settings.eventId !== event.id) {
513
+ throw new SimpleError({
514
+ code: 'invalid_group',
515
+ message: 'Group is already linked to another event',
496
516
  });
497
517
  }
498
518
 
499
- address.throwIfIncomplete();
519
+ if (existingGroup.type !== GroupType.EventRegistration) {
520
+ if (existingGroup.type !== GroupType.Membership) {
521
+ throw new SimpleError({
522
+ code: 'invalid_group',
523
+ message: 'Can only link a group of type EventRegistration or Membership',
524
+ });
525
+ }
526
+ existingGroup.type = GroupType.EventRegistration;
527
+ }
528
+
529
+ existingGroup.settings.eventId = event.id;
530
+
531
+ // update group categories
532
+ const period = await RegistrationPeriod.getByID(existingGroup.periodId);
533
+ if (!period) {
534
+ throw new SimpleError({
535
+ code: 'not_found',
536
+ message: 'No period found for group',
537
+ });
538
+ }
539
+
540
+ const organizationPeriod = await OrganizationRegistrationPeriod.select().where('organizationId', existingGroup.organizationId).where('periodId', period.id).first(true);
541
+ if (!organizationPeriod) {
542
+ throw new SimpleError({
543
+ code: 'not_found',
544
+ message: 'No organization period found for group',
545
+ });
546
+ }
547
+ event.groupId = existingGroup.id;
548
+ await event.save();
549
+
550
+ try {
551
+ await AuditLogService.setContext({ source: AuditLogSource.System }, async () => {
552
+ await event.syncGroupRequirements(existingGroup);
553
+ });
554
+ }
555
+ catch (e) {
556
+ // reset the group
557
+ existingGroup.type = originalGroupType;
558
+ existingGroup.settings.eventId = null;
559
+ await existingGroup.save();
560
+
561
+ // delete the event again if failed to link the group
562
+ await event.delete();
563
+ throw e;
564
+ }
565
+
566
+ const allGroups = await Group.getAll(organizationPeriod.organizationId, organizationPeriod.periodId);
567
+ await organizationPeriod.cleanCategories(allGroups);
500
568
  }
501
569
  }
@@ -3,7 +3,7 @@ import { AutoEncoderPatchType, ConvertArrayToPatchableArray, Decoder, isEmptyPat
3
3
  import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
4
4
  import { SimpleError } from '@simonbackx/simple-errors';
5
5
  import { AuditLog, BalanceItem, Document, Group, Member, MemberFactory, MemberPlatformMembership, MemberResponsibilityRecord, MemberWithRegistrations, MemberWithUsersAndRegistrations, MemberWithUsersRegistrationsAndGroups, mergeTwoMembers, Organization, Platform, RateLimiter, Registration, RegistrationPeriod, User } from '@stamhoofd/models';
6
- import { AuditLogReplacement, AuditLogReplacementType, AuditLogSource, AuditLogType, EmergencyContact, GroupType, MemberDetails, MemberResponsibility, MembersBlob, MemberWithRegistrationsBlob, Parent, PermissionLevel, SetupStepType } from '@stamhoofd/structures';
6
+ import { AuditLogReplacement, AuditLogReplacementType, AuditLogSource, AuditLogType, EmergencyContact, GroupType, MemberDetails, MemberResponsibility, MembersBlob, MemberWithRegistrationsBlob, Parent, PermissionLevel, PlatformMembershipTypeBehaviour, SetupStepType } from '@stamhoofd/structures';
7
7
  import { Formatter } from '@stamhoofd/utility';
8
8
 
9
9
  import { Email } from '@stamhoofd/email';
@@ -535,14 +535,41 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
535
535
  )
536
536
  .first(false);
537
537
 
538
- if (existing) {
538
+ if (existing && (membershipType.behaviour === PlatformMembershipTypeBehaviour.Days || !existing.generated || existing.locked || existing.price < membership.price)) {
539
+ if (membershipType.behaviour === PlatformMembershipTypeBehaviour.Days) {
540
+ throw new SimpleError({
541
+ code: 'invalid_field',
542
+ field: 'startDate',
543
+ message: 'Overlapping memberships',
544
+ human: $t(`Je kan geen aansluiting toevoegen die overlapt met een bestaande aansluiting.`),
545
+ });
546
+ }
547
+ if (existing.locked) {
548
+ throw new SimpleError({
549
+ code: 'invalid_field',
550
+ field: 'startDate',
551
+ message: 'Overlapping memberships',
552
+ human: $t(`Je kan geen aansluiting toevoegen die overlapt met een bestaande aansluiting die al vergendeld is.`),
553
+ });
554
+ }
555
+ if (!existing.generated) {
556
+ throw new SimpleError({
557
+ code: 'invalid_field',
558
+ field: 'startDate',
559
+ message: 'Overlapping memberships',
560
+ human: $t(`Je kan geen aansluiting toevoegen die overlapt met een bestaande aansluiting die manueel werd aangemaakt.`),
561
+ });
562
+ }
539
563
  throw new SimpleError({
540
564
  code: 'invalid_field',
541
565
  field: 'startDate',
542
- message: 'Invalid start date',
543
- human: $t(`faf8b6bb-2727-4d2f-847f-203cf3979dfb`),
566
+ message: 'Overlapping memberships',
567
+ human: $t(`Je kan geen aansluiting toevoegen die overlapt met een bestaande aansluiting die goedkoper is.`),
544
568
  });
545
569
  }
570
+ else if (existing) {
571
+ await existing.doDelete();
572
+ }
546
573
 
547
574
  // Save if okay
548
575
  await membership.save();
@@ -0,0 +1,87 @@
1
+ import { GroupFactory, OrganizationFactory, RegistrationPeriodFactory } from '@stamhoofd/models';
2
+ import { checkShouldSetCustomDates } from './1752848561-groups-registration-periods.js';
3
+
4
+ describe('migration.groupsRegistrationPeriods', () => {
5
+ describe('checkShouldSetCustomDate', () => {
6
+ const cases: { group: { startDate: Date; endDate: Date }; period: { startDate: Date; endDate: Date }; expected: boolean }[] = [
7
+ // case 1
8
+ {
9
+ group: {
10
+ startDate: new Date(2023, 0, 1),
11
+ endDate: new Date(2023, 11, 31),
12
+ },
13
+ period: {
14
+ startDate: new Date(2023, 0, 1),
15
+ endDate: new Date(2023, 11, 31),
16
+ },
17
+ expected: false,
18
+ },
19
+ // case 2
20
+ {
21
+ group: {
22
+ startDate: new Date(2023, 0, 1),
23
+ endDate: new Date(2023, 11, 31),
24
+ },
25
+ period: {
26
+ startDate: new Date(2023, 0, 29),
27
+ endDate: new Date(2023, 11, 31),
28
+ },
29
+ expected: false,
30
+ },
31
+ // case 3
32
+ {
33
+ group: {
34
+ startDate: new Date(2023, 0, 1),
35
+ endDate: new Date(2023, 11, 31),
36
+ },
37
+ period: {
38
+ startDate: new Date(2023, 0, 1),
39
+ endDate: new Date(2024, 0, 28),
40
+ },
41
+ expected: false,
42
+ },
43
+ // case 4
44
+ {
45
+ group: {
46
+ startDate: new Date(2023, 0, 1),
47
+ endDate: new Date(2023, 11, 31),
48
+ },
49
+ period: {
50
+ startDate: new Date(2023, 1, 1),
51
+ endDate: new Date(2023, 11, 31),
52
+ },
53
+ expected: true,
54
+ },
55
+ // case 5
56
+ {
57
+ group: {
58
+ startDate: new Date(2023, 0, 1),
59
+ endDate: new Date(2023, 11, 31),
60
+ },
61
+ period: {
62
+ startDate: new Date(2023, 0, 1),
63
+ endDate: new Date(2023, 10, 30),
64
+ },
65
+ expected: true,
66
+ },
67
+ ];
68
+
69
+ cases.forEach((testCase, index) => {
70
+ it(`Case ${index + 1}`, async () => {
71
+ // arrange
72
+ const period = await new RegistrationPeriodFactory(testCase.period).create();
73
+ const organization = await new OrganizationFactory({ period }).create();
74
+ period.organizationId = organization.id;
75
+ await period.save();
76
+ const group = await new GroupFactory({ organization, period }).create();
77
+ group.settings.startDate = testCase.group.startDate;
78
+ group.settings.endDate = testCase.group.endDate;
79
+ await group.save();
80
+
81
+ // act
82
+ const result = checkShouldSetCustomDates(group, period);
83
+ expect(result).toBe(testCase.expected);
84
+ });
85
+ });
86
+ });
87
+ });
@@ -119,6 +119,7 @@ async function migrateGroups({ groups, organization, periodSpan }: { groups: Gro
119
119
  const currentCycle = originalGroup.cycle;
120
120
  const originalGroupId: string = originalGroup.id;
121
121
  originalGroup.periodId = currentGroupPeriod.id;
122
+ originalGroup.settings.hasCustomDates = checkShouldSetCustomDates(originalGroup, currentGroupPeriod);
122
123
 
123
124
  // first migrate registrations for the current cycle
124
125
  await migrateRegistrations({ organization, period: currentGroupPeriod, originalGroup, newGroup: originalGroup, cycle: currentCycle }, dryRun);
@@ -360,6 +361,24 @@ async function migrateGroups({ groups, organization, periodSpan }: { groups: Gro
360
361
  return result;
361
362
  }
362
363
 
364
+ export function checkShouldSetCustomDates(group: Group, period: RegistrationPeriod): boolean {
365
+ // if difference between period start and group start, or between period and group end is bigger than 1 month
366
+ return isDifferenceBiggerThanMonth(period.startDate, group.settings.startDate) || isDifferenceBiggerThanMonth(period.endDate, group.settings.endDate);
367
+ }
368
+
369
+ /**
370
+ * True if difference larger than 30 days.
371
+ * @param date1
372
+ * @param date2
373
+ */
374
+ function isDifferenceBiggerThanMonth(date1: Date, date2: Date): boolean {
375
+ const diffMs = Math.abs(date1.getTime() - date2.getTime());
376
+ const msPerDay = 24 * 60 * 60 * 1000;
377
+ const diffDays = diffMs / msPerDay;
378
+
379
+ return diffDays > 30;
380
+ }
381
+
363
382
  async function migrateRegistrations({ organization, period, originalGroup, newGroup, cycle }: { organization: Organization; period: RegistrationPeriod; originalGroup: Group; newGroup: Group; cycle: number }, dryRun: boolean) {
364
383
  // what for waiting lists of archive groups (previous cycles)?
365
384
  let waitingList: Group | null = null;
@@ -742,6 +761,7 @@ function createPreviousGroup({ originalGroup, period, cycleInformation, index }:
742
761
  name: new TranslatedString(`${originalSettings.name.toString()} (${extraName})`),
743
762
  startDate,
744
763
  endDate,
764
+ hasCustomDates: true,
745
765
  });
746
766
  newGroup.type = originalGroup.type;
747
767
 
@@ -251,22 +251,40 @@ export class PlatformMembershipService {
251
251
  const cheapestMembership = defaultMembershipsWithOrganization.sort((a, b) => {
252
252
  const tagIdsA = a.organization?.meta.tags ?? [];
253
253
  const tagIdsB = b.organization?.meta.tags ?? [];
254
- const aPrice = a.membership.getPrice(
254
+ let aPrice = a.membership.getPrice(
255
255
  period.id,
256
256
  a.registration.startDate ?? a.registration.registeredAt ?? a.registration.createdAt,
257
257
  tagIdsA,
258
258
  shouldApplyReducedPrice,
259
259
  ) ?? 10000000;
260
- const bPrice = b.membership.getPrice(
260
+ let bPrice = b.membership.getPrice(
261
261
  period.id,
262
262
  b.registration.startDate ?? b.registration.registeredAt ?? b.registration.createdAt,
263
263
  tagIdsB,
264
264
  shouldApplyReducedPrice,
265
265
  ) ?? 10000000;
266
266
 
267
+ const chargeVia = platform.membershipOrganizationId;
268
+ if (a.registration.organizationId === chargeVia) {
269
+ aPrice = 0;
270
+ }
271
+
272
+ if (b.registration.organizationId === chargeVia) {
273
+ bPrice = 0;
274
+ }
275
+
267
276
  const diff = aPrice - bPrice;
268
277
  if (diff === 0) {
269
- return Sorter.byDateValue(b.registration.startDate ?? b.registration.registeredAt ?? b.registration.createdAt, a.registration.startDate ?? a.registration.registeredAt ?? a.registration.createdAt);
278
+ return Sorter.stack(
279
+ Sorter.byDateValue(
280
+ b.registration.startDate ?? b.registration.registeredAt ?? b.registration.createdAt,
281
+ a.registration.startDate ?? a.registration.registeredAt ?? a.registration.createdAt,
282
+ ),
283
+ Sorter.byDateValue(
284
+ b.registration.createdAt,
285
+ a.registration.createdAt,
286
+ ),
287
+ );
270
288
  }
271
289
  return diff;
272
290
  })[0];
@@ -291,10 +309,47 @@ export class PlatformMembershipService {
291
309
  }
292
310
  }
293
311
 
312
+ const membership = new MemberPlatformMembership();
313
+
314
+ if (!didFind) {
315
+ // Calculate price - do not save yet
316
+ const periodConfig = cheapestMembership.membership.periods.get(period.id);
317
+ if (!periodConfig) {
318
+ console.error('Missing membership prices for membership type ' + cheapestMembership.membership.id + ' and period ' + period.id);
319
+ continue;
320
+ }
321
+
322
+ // Pre calculate the price
323
+ membership.memberId = me.id;
324
+ membership.membershipTypeId = cheapestMembership.membership.id;
325
+ membership.organizationId = cheapestMembership.registration.organizationId;
326
+ membership.periodId = period.id;
327
+
328
+ // Note: the dates will get modified in the price calculation
329
+ membership.startDate = periodConfig.startDate;
330
+ membership.endDate = periodConfig.endDate;
331
+ membership.expireDate = periodConfig.expireDate;
332
+ membership.generated = true;
333
+ await membership.calculatePrice(me, cheapestMembership.registration);
334
+
335
+ // Check if we have a not-locked but non-generated one that is cheaper
336
+ // if so, we stop and don't create a new one (but still delete others if required)
337
+ for (const m of activeMemberships) {
338
+ if (m.membershipTypeId === cheapestMembership.membership.id && !m.generated) {
339
+ // is this cheaper?
340
+ if (m.price <= membership.price) { // todo expand with more details price comparison
341
+ // Cheaper
342
+ didFind = m;
343
+ break;
344
+ }
345
+ }
346
+ }
347
+ }
348
+
294
349
  // Then update all memberships from the same organization for the selected registration date range
295
350
  for (const m of activeMemberships) {
296
351
  if (m.membershipTypeId === cheapestMembership.membership.id && m.organizationId === cheapestMembership.registration.organizationId) {
297
- if (!m.locked) {
352
+ if (!m.locked && m.generated) {
298
353
  // Update the price and dates of this active membership (could have changed)
299
354
  try {
300
355
  await m.calculatePrice(me, cheapestMembership.registration);
@@ -323,7 +378,7 @@ export class PlatformMembershipService {
323
378
  }
324
379
  await m.doDelete();
325
380
  }
326
- else {
381
+ else if (m.membershipTypeId === cheapestMembership.membership.id) {
327
382
  // Update price
328
383
  if (!m.locked) {
329
384
  try {
@@ -346,31 +401,11 @@ export class PlatformMembershipService {
346
401
  continue;
347
402
  }
348
403
 
349
- // Otherwise make sure we create a new membership
350
-
351
- const periodConfig = cheapestMembership.membership.periods.get(period.id);
352
- if (!periodConfig) {
353
- console.error('Missing membership prices for membership type ' + cheapestMembership.membership.id + ' and period ' + period.id);
354
- continue;
355
- }
356
-
357
404
  // Can we revive an earlier deleted membership?
358
405
  if (!silent) {
359
406
  console.log('Creating automatic membership for: ' + me.id + ' - membership type ' + cheapestMembership.membership.id);
360
407
  }
361
408
 
362
- const membership = new MemberPlatformMembership();
363
- membership.memberId = me.id;
364
- membership.membershipTypeId = cheapestMembership.membership.id;
365
- membership.organizationId = cheapestMembership.registration.organizationId;
366
- membership.periodId = period.id;
367
-
368
- // Note: the dates will get modified in the price calculation
369
- membership.startDate = periodConfig.startDate;
370
- membership.endDate = periodConfig.endDate;
371
- membership.expireDate = periodConfig.expireDate;
372
- membership.generated = true;
373
-
374
409
  if (me.details.memberNumber === null) {
375
410
  try {
376
411
  await MemberNumberService.assignMemberNumber(me, membership);
@@ -382,7 +417,6 @@ export class PlatformMembershipService {
382
417
  }
383
418
  }
384
419
 
385
- await membership.calculatePrice(me, cheapestMembership.registration);
386
420
  await membership.save();
387
421
  }
388
422
  });