@stamhoofd/backend 2.103.0 → 2.104.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.
@@ -3,7 +3,8 @@ import { GroupPrivateSettings, Group as GroupStruct, GroupType, OrganizationRegi
3
3
 
4
4
  import { AutoEncoderPatchType, Decoder, PatchableArrayAutoEncoder, PatchableArrayDecoder, StringDecoder } from '@simonbackx/simple-encoding';
5
5
  import { SimpleError } from '@simonbackx/simple-errors';
6
- import { Event, Group, Organization, OrganizationRegistrationPeriod, Platform, RegistrationPeriod } from '@stamhoofd/models';
6
+ import { Event, Group, Organization, OrganizationRegistrationPeriod, Platform, Registration, RegistrationPeriod } from '@stamhoofd/models';
7
+ import { SQL } from '@stamhoofd/sql';
7
8
  import { AuthenticatedStructures } from '../../../../helpers/AuthenticatedStructures';
8
9
  import { Context } from '../../../../helpers/Context';
9
10
  import { SetupStepUpdater } from '../../../../helpers/SetupStepUpdater';
@@ -75,7 +76,7 @@ export class PatchOrganizationRegistrationPeriodsEndpoint extends Endpoint<Param
75
76
  });
76
77
  }
77
78
 
78
- if (period.locked) {
79
+ if ((!period.organizationId && period.locked) || (period.locked && period.organizationId && !await Context.auth.hasFullAccess(period.organizationId))) {
79
80
  throw new SimpleError({
80
81
  code: 'locked_period',
81
82
  message: 'This period is locked',
@@ -375,7 +376,9 @@ export class PatchOrganizationRegistrationPeriodsEndpoint extends Endpoint<Param
375
376
  }
376
377
  }
377
378
 
378
- static async patchGroup(struct: AutoEncoderPatchType<GroupStruct>, period?: RegistrationPeriod | null) {
379
+ static async patchGroup(struct: AutoEncoderPatchType<GroupStruct>, period?: RegistrationPeriod | null, options: { allowPatchWaitingListPeriod?: boolean; isPatchingEvent?: boolean } = {}) {
380
+ const { allowPatchWaitingListPeriod = false, isPatchingEvent = false } = options;
381
+
379
382
  const model = await Group.getByID(struct.id);
380
383
 
381
384
  if (!model || !await Context.auth.canAccessGroup(model, PermissionLevel.Full)) {
@@ -417,16 +420,87 @@ export class PatchOrganizationRegistrationPeriodsEndpoint extends Endpoint<Param
417
420
 
418
421
  if (!period && !model.settings.period) {
419
422
  period = await RegistrationPeriod.getByID(model.periodId);
420
- }
421
423
 
422
- if (period) {
423
- if (STAMHOOFD.userMode === 'organization' && period.organizationId !== model.organizationId) {
424
+ if (!period) {
424
425
  throw new SimpleError({
425
- code: 'invalid_period',
426
- message: 'Period has different organization id',
427
- statusCode: 400,
426
+ code: 'missing_required_relation',
427
+ message: 'Relation periodId is missing on group ' + model.id,
428
+ statusCode: 500,
428
429
  });
429
430
  }
431
+ }
432
+
433
+ let shouldUpdatePeriodIds = false;
434
+
435
+ if (period) {
436
+ shouldUpdatePeriodIds = period.id !== model.periodId;
437
+
438
+ if (shouldUpdatePeriodIds && period.locked) {
439
+ if (period.organizationId === null || !await Context.auth.hasFullAccess(period.organizationId)) {
440
+ throw new SimpleError({
441
+ code: 'locked_period',
442
+ message: 'This period is locked',
443
+ human: Context.i18n.$t('f544b972-416c-471d-8836-d7f3b16f947d'),
444
+ });
445
+ }
446
+ }
447
+
448
+ if (shouldUpdatePeriodIds) {
449
+ if (!isPatchingEvent && struct.periodId === undefined) {
450
+ throw new SimpleError({
451
+ code: 'invalid_field',
452
+ field: 'periodId',
453
+ message: 'Setting periodId is required when moving a group to a new period',
454
+ });
455
+ }
456
+
457
+ if (STAMHOOFD.userMode === 'organization' && period.organizationId !== model.organizationId) {
458
+ throw new SimpleError({
459
+ code: 'invalid_period',
460
+ message: 'Period has different organization id',
461
+ statusCode: 400,
462
+ });
463
+ }
464
+
465
+ // Check current period is locked
466
+ const currentPeriod = await RegistrationPeriod.getByID(model.periodId);
467
+
468
+ if (!currentPeriod) {
469
+ throw new SimpleError({
470
+ code: 'missing_required_relation',
471
+ message: 'Relation periodId is missing on group ' + model.id,
472
+ statusCode: 500,
473
+ });
474
+ }
475
+
476
+ if (currentPeriod.locked) {
477
+ if (currentPeriod.organizationId === null || !await Context.auth.hasFullAccess(currentPeriod.organizationId)) {
478
+ throw new SimpleError({
479
+ code: 'locked_period',
480
+ message: 'This period is locked',
481
+ human: $t('e115a9d1-11a6-42b9-a781-c6ab9d8a4b9c'),
482
+ });
483
+ }
484
+ }
485
+
486
+ if ((!isPatchingEvent && model.type === GroupType.EventRegistration) || (model.type === GroupType.WaitingList && !allowPatchWaitingListPeriod)) {
487
+ throw new SimpleError({
488
+ code: 'not_supported',
489
+ message: `Moving group with type ${model.type} to a different period is not supported`,
490
+ statusCode: 400,
491
+ });
492
+ }
493
+ }
494
+
495
+ if (!isPatchingEvent) {
496
+ if (struct.periodId !== undefined && struct.periodId !== period.id) {
497
+ throw new SimpleError({
498
+ code: 'invalid_field',
499
+ field: 'periodId',
500
+ message: 'Cannot patch periodId to a different period then the one being patched',
501
+ });
502
+ }
503
+ }
430
504
 
431
505
  model.periodId = period.id;
432
506
  model.settings.period = period.getBaseStructure();
@@ -447,6 +521,27 @@ export class PatchOrganizationRegistrationPeriodsEndpoint extends Endpoint<Param
447
521
  }
448
522
  }
449
523
 
524
+ /**
525
+ * A group should only be able to be moved to another period if the waiting list is not shared with other groups in the current period.
526
+ */
527
+ const throwIfUpdateWaitingListPeriodWithMultipleGroups = async (waitingListId: string) => {
528
+ if (shouldUpdatePeriodIds) {
529
+ const groupCount = await Group.select()
530
+ .where('waitingListId', waitingListId)
531
+ .whereNot('id', model.id)
532
+ .whereNot('periodId', model.periodId).limit(1).count();
533
+
534
+ if (groupCount > 0) {
535
+ throw new SimpleError({
536
+ code: 'invalid_field',
537
+ field: 'periodId',
538
+ message: 'Group has waiting list with other groups in the current period',
539
+ human: $t('753684e1-94aa-4663-a81c-9656a51283ae'),
540
+ });
541
+ }
542
+ }
543
+ };
544
+
450
545
  const patch = struct;
451
546
  if (patch.waitingList !== undefined) {
452
547
  if (patch.waitingList === null) {
@@ -467,7 +562,11 @@ export class PatchOrganizationRegistrationPeriodsEndpoint extends Endpoint<Param
467
562
  }
468
563
  patch.waitingList.id = model.waitingListId;
469
564
  patch.waitingList.type = GroupType.WaitingList;
470
- await PatchOrganizationRegistrationPeriodsEndpoint.patchGroup(patch.waitingList, period);
565
+ await throwIfUpdateWaitingListPeriodWithMultipleGroups(patch.waitingList.id);
566
+ await PatchOrganizationRegistrationPeriodsEndpoint.patchGroup(patch.waitingList, period, {
567
+ allowPatchWaitingListPeriod: shouldUpdatePeriodIds,
568
+ isPatchingEvent,
569
+ });
471
570
  }
472
571
  else {
473
572
  if (model.waitingListId) {
@@ -525,6 +624,27 @@ export class PatchOrganizationRegistrationPeriodsEndpoint extends Endpoint<Param
525
624
  }
526
625
  }
527
626
  }
627
+ else if (shouldUpdatePeriodIds && model.waitingListId && period) {
628
+ // update waiting list period
629
+ await throwIfUpdateWaitingListPeriodWithMultipleGroups(model.waitingListId);
630
+ await PatchOrganizationRegistrationPeriodsEndpoint.patchGroup(
631
+ GroupStruct.patch({
632
+ id: model.waitingListId,
633
+ periodId: period.id,
634
+ }),
635
+ period,
636
+ {
637
+ allowPatchWaitingListPeriod: shouldUpdatePeriodIds,
638
+ isPatchingEvent,
639
+ },
640
+ );
641
+ }
642
+
643
+ if (shouldUpdatePeriodIds) {
644
+ // change the period ids of the registrations in the group
645
+ const { changedRows } = await SQL.update(Registration.table).set('periodId', model.periodId).where('groupId', model.id).update();
646
+ console.log(`Moved ${changedRows} registrations to period ${model.periodId}`);
647
+ }
528
648
 
529
649
  model.settings.throwIfInvalidPrices();
530
650
  await model.updateOccupancy();
@@ -216,20 +216,28 @@ export class AdminPermissionChecker {
216
216
  return true;
217
217
  }
218
218
 
219
+ async canAccessGroupsInPeriod(periodId: string, organizationId: string) {
220
+ const organization = await this.getOrganization(organizationId);
221
+ if (periodId !== organization.periodId) {
222
+ if (STAMHOOFD.userMode === 'organization' || periodId !== this.platform.period.id) {
223
+ if (!await this.hasFullAccess(organization.id)) {
224
+ return false;
225
+ }
226
+ }
227
+ }
228
+ return true;
229
+ }
230
+
219
231
  async canAccessGroup(group: Group, permissionLevel: PermissionLevel = PermissionLevel.Read): Promise<boolean> {
220
232
  // Check permissions aren't scoped to a specific organization, and they mismatch
221
233
  if (!this.checkScope(group.organizationId)) {
222
234
  // return false;
223
235
  }
224
- const organization = await this.getOrganization(group.organizationId);
225
236
 
226
- if (group.periodId !== organization.periodId) {
227
- if (STAMHOOFD.userMode === 'organization' || group.periodId !== this.platform.period.id) {
228
- if (!await this.hasFullAccess(group.organizationId)) {
229
- return false;
230
- }
231
- }
237
+ if (!await this.canAccessGroupsInPeriod(group.periodId, group.organizationId)) {
238
+ return false;
232
239
  }
240
+ const organization = await this.getOrganization(group.organizationId);
233
241
 
234
242
  if (group.deletedAt || group.status === GroupStatus.Archived) {
235
243
  return await this.canAccessArchivedGroups(group.organizationId);
@@ -155,7 +155,7 @@ export class PeriodHelper {
155
155
  await QueueHandler.schedule(tag, async () => {
156
156
  await AuditLogService.setContext({ source: AuditLogSource.System }, async () => {
157
157
  for await (const group of Group.select().where('periodId', period.id).all()) {
158
- await PatchOrganizationRegistrationPeriodsEndpoint.patchGroup(GroupStruct.patch({ id: group.id }), period);
158
+ await PatchOrganizationRegistrationPeriodsEndpoint.patchGroup(GroupStruct.patch({ id: group.id, periodId: period.id }), period);
159
159
  }
160
160
  });
161
161
  });
@@ -1,10 +1,14 @@
1
1
  import { IPaginatedResponse, LimitedFilteredRequest } from '@stamhoofd/structures';
2
+ import { FileSignService } from '../services/FileSignService';
2
3
 
3
4
  export function fetchToAsyncIterator<T>(
4
5
  initialFilter: LimitedFilteredRequest,
5
6
  loader: {
6
7
  fetch(request: LimitedFilteredRequest): Promise<IPaginatedResponse<T, LimitedFilteredRequest>>;
7
8
  },
9
+ options?: {
10
+ signFiles?: boolean;
11
+ },
8
12
  ): AsyncIterable<T> {
9
13
  return {
10
14
  [Symbol.asyncIterator]: function () {
@@ -20,6 +24,9 @@ export function fetchToAsyncIterator<T>(
20
24
  }
21
25
 
22
26
  const response = await loader.fetch(request);
27
+ if (options?.signFiles) {
28
+ await FileSignService.fillSignedUrlsForStruct(response.results);
29
+ }
23
30
  request = response.next ?? null;
24
31
 
25
32
  return {
@@ -253,7 +253,7 @@ export const PaymentService = {
253
253
  await payconiqPayment.cancel(organization);
254
254
  }
255
255
 
256
- let status = await payconiqPayment.getStatus(organization);
256
+ let status = await payconiqPayment.getStatus(organization, payment);
257
257
 
258
258
  if (!cancel && this.shouldTryToCancel(status, payment)) {
259
259
  console.error('Manually cancelling Payconiq payment', payment.id);
@@ -1,7 +1,13 @@
1
1
  import { BalanceItem } from '@stamhoofd/models';
2
2
  import { BalanceItemService } from '../../src/services/BalanceItemService';
3
3
 
4
- export async function assertBalances(selector: { user: { id: string | null } } | { member: { id: string | null } }, balances: Partial<BalanceItem>[]) {
4
+ export async function assertBalances(
5
+ selector:
6
+ { user: { id: string | null } }
7
+ | { member: { id: string | null } }
8
+ | { organization: { id: string | null } }
9
+ | { payingOrganization: { id: string | null } },
10
+ balances: Partial<BalanceItem>[]) {
5
11
  await BalanceItemService.flushAll();
6
12
 
7
13
  // Fetch all user balances
@@ -12,6 +18,12 @@ export async function assertBalances(selector: { user: { id: string | null } } |
12
18
  else if ('member' in selector && selector.member.id) {
13
19
  q.where('memberId', selector.member.id);
14
20
  }
21
+ else if ('organization' in selector && selector.organization.id) {
22
+ q.where('organizationId', selector.organization.id);
23
+ }
24
+ else if ('payingOrganization' in selector && selector.payingOrganization.id) {
25
+ q.where('payingOrganizationId', selector.payingOrganization.id);
26
+ }
15
27
  else {
16
28
  throw new Error('Selector must contain either user or member with an id');
17
29
  }
@@ -40,6 +40,10 @@ export class PayconiqMocker {
40
40
 
41
41
  return [200, {
42
42
  paymentId: uuidv4(),
43
+ debtor: {
44
+ name: 'Test User',
45
+ iban: 'BE41878878996410', // random iban
46
+ },
43
47
  _links: {
44
48
  checkout: {
45
49
  href: 'https://payconiq.com/pay/2/5bdb1685b93d1c000bde96f2?token=530ea8a4ec8ded7d87620c8637354022cd965b143f257f8f8cb118e7f4a22d8f&returnUrl=https%3A%2F%2Fummy.webshop%2Fcheckout%2Fsuccess',
@@ -59,6 +63,10 @@ export class PayconiqMocker {
59
63
 
60
64
  return [200, {
61
65
  paymentId: uuidv4(),
66
+ debtor: {
67
+ name: 'Test User',
68
+ iban: 'BE41878878996410', // random iban
69
+ },
62
70
  _links: {
63
71
  checkout: {
64
72
  href: 'https://payconiq.com/pay/2/5bdb1685b93d1c000bde96f2?token=530ea8a4ec8ded7d87620c8637354022cd965b143f257f8f8cb118e7f4a22d8f&returnUrl=https%3A%2F%2Fummy.webshop%2Fcheckout%2Fsuccess',
@@ -51,7 +51,6 @@ beforeAll(async () => {
51
51
  await Database.delete('DELETE FROM `webshops`');
52
52
  await Database.delete('DELETE FROM `groups`');
53
53
  await Database.delete('DELETE FROM `email_addresses`');
54
-
55
54
  await Database.update('UPDATE registration_periods set organizationId = null, customName = ? where organizationId is not null', ['delete']);
56
55
  await Database.delete('DELETE FROM `organizations`');
57
56
  await Database.delete('DELETE FROM `registration_periods` where customName = ?', ['delete']);