@stamhoofd/backend 2.83.4 → 2.84.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 (100) hide show
  1. package/index.ts +19 -4
  2. package/package.json +18 -14
  3. package/src/crons/amazon-ses.ts +26 -5
  4. package/src/crons/balance-emails.ts +18 -17
  5. package/src/email-recipient-loaders/registrations.ts +87 -0
  6. package/src/endpoints/global/addresses/SearchRegionsEndpoint.ts +5 -2
  7. package/src/endpoints/global/email/PatchEmailEndpoint.test.ts +40 -40
  8. package/src/endpoints/global/events/PatchEventNotificationsEndpoint.test.ts +28 -22
  9. package/src/endpoints/global/events/PatchEventsEndpoint.ts +81 -49
  10. package/src/endpoints/global/files/UploadFile.ts +11 -16
  11. package/src/endpoints/global/groups/GetGroupsEndpoint.test.ts +234 -0
  12. package/src/endpoints/global/groups/GetGroupsEndpoint.ts +117 -43
  13. package/src/endpoints/global/members/GetMembersEndpoint.test.ts +1054 -0
  14. package/src/endpoints/global/members/GetMembersEndpoint.ts +163 -141
  15. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.test.ts +6 -6
  16. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +0 -16
  17. package/src/endpoints/global/members/helpers/validateGroupFilter.ts +73 -0
  18. package/src/endpoints/global/registration/GetPaymentRegistrations.ts +1 -2
  19. package/src/endpoints/global/registration/GetRegistrationsCountEndpoint.ts +43 -0
  20. package/src/endpoints/global/registration/GetRegistrationsEndpoint.test.ts +1016 -0
  21. package/src/endpoints/global/registration/GetRegistrationsEndpoint.ts +234 -0
  22. package/src/endpoints/global/registration/PatchUserMembersEndpoint.test.ts +5 -5
  23. package/src/endpoints/global/registration/RegisterMembersEndpoint.test.ts +474 -554
  24. package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +191 -52
  25. package/src/endpoints/global/registration-periods/GetRegistrationPeriodsEndpoint.ts +107 -9
  26. package/src/endpoints/organization/dashboard/email-templates/GetEmailTemplatesEndpoint.test.ts +89 -0
  27. package/src/endpoints/organization/dashboard/email-templates/GetEmailTemplatesEndpoint.ts +9 -6
  28. package/src/endpoints/organization/dashboard/email-templates/PatchEmailTemplatesEndpoint.test.ts +88 -0
  29. package/src/endpoints/organization/dashboard/email-templates/PatchEmailTemplatesEndpoint.ts +0 -6
  30. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +10 -6
  31. package/src/endpoints/organization/dashboard/payments/GetMemberBalanceEndpoint.ts +10 -25
  32. package/src/endpoints/organization/dashboard/payments/PatchBalanceItemsEndpoint.ts +0 -5
  33. package/src/endpoints/organization/dashboard/payments/PatchPaymentsEndpoint.ts +0 -5
  34. package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalanceEndpoint.ts +4 -0
  35. package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalancesEndpoint.ts +1 -0
  36. package/src/endpoints/organization/dashboard/registration-periods/GetOrganizationRegistrationPeriodsEndpoint.test.ts +44 -19
  37. package/src/endpoints/organization/dashboard/registration-periods/GetOrganizationRegistrationPeriodsEndpoint.ts +140 -25
  38. package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.ts +40 -10
  39. package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.test.ts +2 -2
  40. package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.test.ts +2 -2
  41. package/src/endpoints/organization/dashboard/webshops/PatchWebshopEndpoint.ts +4 -1
  42. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +2 -2
  43. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +2 -2
  44. package/src/excel-loaders/members.ts +233 -232
  45. package/src/excel-loaders/payments.ts +1 -1
  46. package/src/excel-loaders/receivable-balances.ts +1 -1
  47. package/src/excel-loaders/registrations.ts +153 -0
  48. package/src/helpers/AdminPermissionChecker.ts +65 -37
  49. package/src/helpers/AuthenticatedStructures.ts +43 -3
  50. package/src/helpers/Context.ts +29 -1
  51. package/src/helpers/GlobalHelper.ts +3 -1
  52. package/src/helpers/GroupedThrottledQueue.test.ts +219 -0
  53. package/src/helpers/GroupedThrottledQueue.ts +108 -0
  54. package/src/helpers/LimitedFilteredRequestHelper.ts +26 -1
  55. package/src/helpers/MemberCharger.ts +0 -5
  56. package/src/helpers/MembershipCharger.ts +3 -9
  57. package/src/helpers/OrganizationCharger.ts +0 -5
  58. package/src/helpers/ThrottledQueue.test.ts +194 -0
  59. package/src/helpers/ThrottledQueue.ts +145 -0
  60. package/src/helpers/XlsxTransformerColumnHelper.ts +44 -1
  61. package/src/middleware/ContextMiddleware.ts +1 -1
  62. package/src/seeds/1728928974-update-cached-outstanding-balance-from-items.ts +2 -1
  63. package/src/seeds/1735577912-update-cached-outstanding-balance-from-items.ts +2 -1
  64. package/src/services/BalanceItemPaymentService.ts +1 -33
  65. package/src/services/BalanceItemService.ts +167 -48
  66. package/src/services/FileSignService.ts +18 -13
  67. package/src/services/MemberRecordStore.ts +28 -19
  68. package/src/services/PaymentReallocationService.test.ts +25 -14
  69. package/src/services/PaymentReallocationService.ts +29 -10
  70. package/src/services/PaymentService.ts +4 -16
  71. package/src/services/PlatformMembershipService.ts +8 -4
  72. package/src/services/RegistrationService.ts +66 -2
  73. package/src/sql-filters/base-registration-filter-compilers.ts +43 -0
  74. package/src/sql-filters/groups.ts +67 -0
  75. package/src/sql-filters/members.ts +33 -58
  76. package/src/sql-filters/organization-registration-periods.ts +8 -0
  77. package/src/sql-filters/registration-periods.ts +8 -0
  78. package/src/sql-filters/registrations.ts +11 -22
  79. package/src/sql-sorters/groups.ts +24 -0
  80. package/src/sql-sorters/organization-registration-periods.ts +24 -0
  81. package/src/sql-sorters/registration-periods.ts +47 -0
  82. package/src/sql-sorters/registrations.ts +77 -0
  83. package/tests/actions/patchOrganizationMember.ts +27 -0
  84. package/tests/actions/patchPaymentStatus.ts +45 -0
  85. package/tests/actions/patchUserMember.ts +27 -0
  86. package/tests/assertions/assertBalances.ts +49 -0
  87. package/tests/e2e/api-rate-limits.test.ts +5 -5
  88. package/tests/e2e/bundle-discounts.test.ts +4060 -0
  89. package/tests/e2e/charge-members.test.ts +27 -24
  90. package/tests/e2e/documents.test.ts +398 -0
  91. package/tests/e2e/register.test.ts +292 -312
  92. package/tests/helpers/PayconiqMocker.ts +55 -0
  93. package/tests/init/index.ts +5 -0
  94. package/tests/init/initAdmin.ts +14 -0
  95. package/tests/init/initBundleDiscount.ts +47 -0
  96. package/tests/init/initPayconiq.ts +9 -0
  97. package/tests/init/initPlatformAdmin.ts +13 -0
  98. package/tests/init/initStripe.ts +21 -0
  99. package/tests/jest.setup.ts +29 -0
  100. package/src/seeds-temporary/1736266448-recall-balance-item-price-paid.ts +0 -70
@@ -1,7 +1,7 @@
1
1
  import { AutoEncoderPatchType, PatchMap } from '@simonbackx/simple-encoding';
2
2
  import { isSimpleError, isSimpleErrors, SimpleError } from '@simonbackx/simple-errors';
3
3
  import { BalanceItem, CachedBalance, Document, EmailTemplate, Event, EventNotification, Group, Member, MemberPlatformMembership, MemberWithRegistrations, Order, Organization, OrganizationRegistrationPeriod, Payment, Registration, User, Webshop } from '@stamhoofd/models';
4
- import { AccessRight, EventPermissionChecker, FinancialSupportSettings, GroupCategory, GroupStatus, GroupType, MemberWithRegistrationsBlob, PermissionLevel, PermissionsResourceType, Platform as PlatformStruct, RecordSettings, ResourcePermissions } from '@stamhoofd/structures';
4
+ import { AccessRight, EmailTemplate as EmailTemplateStruct, EventPermissionChecker, FinancialSupportSettings, GroupCategory, GroupStatus, GroupType, MemberWithRegistrationsBlob, PermissionLevel, PermissionsResourceType, Platform as PlatformStruct, RecordSettings, ResourcePermissions } from '@stamhoofd/structures';
5
5
  import { Formatter } from '@stamhoofd/utility';
6
6
  import { MemberRecordStore } from '../services/MemberRecordStore';
7
7
  import { addTemporaryMemberAccess, hasTemporaryMemberAccess } from './TemporaryMemberAccess';
@@ -20,7 +20,7 @@ export class AdminPermissionChecker {
20
20
  platform: PlatformStruct;
21
21
 
22
22
  organizationCache: Map<string, Organization | Promise<Organization | undefined>> = new Map();
23
- organizationGroupsCache: Map<string, Group[] | Promise<Group[]>> = new Map();
23
+ groupsCache: Map<string, Group | null | Promise<Group | null>> = new Map();
24
24
 
25
25
  constructor(user: User, platform: PlatformStruct, organization?: Organization) {
26
26
  this.user = user;
@@ -64,17 +64,30 @@ export class AdminPermissionChecker {
64
64
  return id;
65
65
  }
66
66
 
67
- async getOrganizationGroups(id: string) {
68
- const c = this.organizationGroupsCache.get(id);
69
- if (c) {
70
- return await c;
67
+ async getGroup(groupId: string): Promise<Group | null> {
68
+ const cache = this.groupsCache.get(groupId);
69
+ if (cache !== undefined) {
70
+ return await cache;
71
+ }
72
+
73
+ const promise = Group.select()
74
+ .where('id', groupId)
75
+ .first(false);
76
+
77
+ this.groupsCache.set(groupId, promise);
78
+ const group = await promise;
79
+ this.groupsCache.set(groupId, group);
80
+ return group;
81
+ }
82
+
83
+ cacheGroup(group: Group) {
84
+ this.groupsCache.set(group.id, group);
85
+ }
86
+
87
+ cacheGroups(groups: Group[]) {
88
+ for (const group of groups) {
89
+ this.cacheGroup(group);
71
90
  }
72
- const organization = await this.getOrganization(id);
73
- const promise = Group.getAll(id, organization.periodId, true);
74
- this.organizationGroupsCache.set(id, promise);
75
- const result = await promise;
76
- this.organizationGroupsCache.set(id, result);
77
- return result;
78
91
  }
79
92
 
80
93
  async getOrganizationCurrentPeriod(id: string | Organization): Promise<OrganizationRegistrationPeriod> {
@@ -82,11 +95,14 @@ export class AdminPermissionChecker {
82
95
  return await organization.getPeriod();
83
96
  }
84
97
 
85
- error(message?: string): SimpleError {
98
+ error(humanOrData?: string | { message: string; human?: string }): SimpleError {
99
+ const human = typeof humanOrData === 'string' ? humanOrData : (humanOrData?.human ?? $t(`ab071f11-e05b-4bd9-9370-cd4f220c1b54`));
100
+ const message = typeof humanOrData === 'string' ? humanOrData : (humanOrData?.message ?? 'You do not have permissions for this action');
101
+
86
102
  return new SimpleError({
87
103
  code: 'permission_denied',
88
- message: 'You do not have permissions for this action',
89
- human: message ?? $t(`ab071f11-e05b-4bd9-9370-cd4f220c1b54`),
104
+ message,
105
+ human,
90
106
  statusCode: 403,
91
107
  });
92
108
  }
@@ -205,6 +221,19 @@ export class AdminPermissionChecker {
205
221
  }
206
222
  }
207
223
 
224
+ if (group.type === GroupType.WaitingList) {
225
+ // Check if this is a waiting list for an event
226
+ const parentGroup = await Group.select()
227
+ .where('type', GroupType.EventRegistration)
228
+ .where('organizationId', group.organizationId)
229
+ .where('waitingListId', group.id)
230
+ .first(false);
231
+
232
+ if (parentGroup) {
233
+ return await this.canAccessGroup(parentGroup, permissionLevel);
234
+ }
235
+ }
236
+
208
237
  return false;
209
238
  }
210
239
 
@@ -360,8 +389,7 @@ export class AdminPermissionChecker {
360
389
  return true;
361
390
  }
362
391
 
363
- const allGroups = await this.getOrganizationGroups(registration.organizationId);
364
- const group = allGroups.find(g => g.id === registration.groupId);
392
+ const group = await this.getGroup(registration.groupId);
365
393
  if (!group || group.deletedAt) {
366
394
  return false;
367
395
  }
@@ -649,13 +677,19 @@ export class AdminPermissionChecker {
649
677
  return this.canEditUserName(user);
650
678
  }
651
679
 
652
- async canAccessEmailTemplate(template: EmailTemplate, level: PermissionLevel = PermissionLevel.Read) {
653
- if (level === PermissionLevel.Read) {
680
+ async canAccessEmailTemplate(template: EmailTemplate, level: PermissionLevel = PermissionLevel.Read): Promise<boolean> {
681
+ if (level === PermissionLevel.Read && !EmailTemplateStruct.isSavedEmail(template.type)) {
654
682
  if (template.organizationId === null) {
655
683
  // Public templates
656
- return true;
684
+
685
+ return EmailTemplateStruct.allowPlatformLevel(template.type);
657
686
  }
658
- return this.canReadEmailTemplates(template.organizationId);
687
+
688
+ if (!await this.canReadEmailTemplates(template.organizationId)) {
689
+ return false;
690
+ }
691
+
692
+ return EmailTemplateStruct.allowOrganizationLevel(template.type);
659
693
  }
660
694
 
661
695
  // Note: if the template has an organizationId of null, everyone can access it, but only for reading
@@ -806,13 +840,11 @@ export class AdminPermissionChecker {
806
840
  }
807
841
 
808
842
  async canReadEmailTemplates(organizationId: string) {
809
- const organizationPermissions = await this.getOrganizationPermissions(organizationId);
810
-
811
- if (!organizationPermissions) {
843
+ if (!await this.hasSomeAccess(organizationId) && !this.hasSomePlatformAccess()) {
812
844
  return false;
813
845
  }
814
846
 
815
- return !!this.user.permissions;
847
+ return true;
816
848
  }
817
849
 
818
850
  async canCreateGroupInCategory(organizationId: string, category: GroupCategory) {
@@ -1114,20 +1146,16 @@ export class AdminPermissionChecker {
1114
1146
  };
1115
1147
  }
1116
1148
 
1117
- async getAccessibleGroups(organizationId: string, level: PermissionLevel = PermissionLevel.Read): Promise<string[] | 'all'> {
1118
- if (await this.hasFullAccess(organizationId, level)) {
1119
- return 'all';
1149
+ /**
1150
+ * Performance helper
1151
+ */
1152
+ async canAccessAllMembers(organizationId: string, level: PermissionLevel = PermissionLevel.Read): Promise<boolean> {
1153
+ const permissions = await this.getOrganizationPermissions(organizationId);
1154
+ if (!permissions) {
1155
+ return false;
1120
1156
  }
1121
1157
 
1122
- const groups = await this.getOrganizationGroups(organizationId);
1123
- const accessibleGroups: string[] = [];
1124
-
1125
- for (const group of groups) {
1126
- if (await this.canAccessGroup(group, level)) {
1127
- accessibleGroups.push(group.id);
1128
- }
1129
- }
1130
- return accessibleGroups;
1158
+ return permissions.hasResourceAccess(PermissionsResourceType.Groups, '', level);
1131
1159
  }
1132
1160
 
1133
1161
  /**
@@ -1,11 +1,12 @@
1
1
  import { SimpleError } from '@simonbackx/simple-errors';
2
2
  import { AuditLog, BalanceItem, CachedBalance, Document, Event, EventNotification, Group, Member, MemberPlatformMembership, MemberResponsibilityRecord, MemberWithRegistrations, Order, Organization, OrganizationRegistrationPeriod, Payment, Registration, RegistrationPeriod, Ticket, User, Webshop } from '@stamhoofd/models';
3
- import { AuditLogReplacement, AuditLogReplacementType, AuditLog as AuditLogStruct, DetailedReceivableBalance, Document as DocumentStruct, EventNotification as EventNotificationStruct, Event as EventStruct, GenericBalance, Group as GroupStruct, GroupType, MemberPlatformMembership as MemberPlatformMembershipStruct, MemberWithRegistrationsBlob, MembersBlob, NamedObject, OrganizationRegistrationPeriod as OrganizationRegistrationPeriodStruct, Organization as OrganizationStruct, PaymentGeneral, PermissionLevel, Platform, PrivateOrder, PrivateWebshop, ReceivableBalanceObject, ReceivableBalanceObjectContact, ReceivableBalance as ReceivableBalanceStruct, ReceivableBalanceType, TicketPrivate, UserWithMembers, WebshopPreview, Webshop as WebshopStruct } from '@stamhoofd/structures';
3
+ import { AuditLogReplacement, AuditLogReplacementType, AuditLog as AuditLogStruct, DetailedReceivableBalance, Document as DocumentStruct, EventNotification as EventNotificationStruct, Event as EventStruct, GenericBalance, Group as GroupStruct, GroupType, MemberPlatformMembership as MemberPlatformMembershipStruct, MembersBlob, MemberWithRegistrationsBlob, NamedObject, OrganizationRegistrationPeriod as OrganizationRegistrationPeriodStruct, Organization as OrganizationStruct, PaymentGeneral, PermissionLevel, Platform, PrivateOrder, PrivateWebshop, ReceivableBalanceObject, ReceivableBalanceObjectContact, ReceivableBalance as ReceivableBalanceStruct, ReceivableBalanceType, RegistrationsBlob, RegistrationWithMemberBlob, TicketPrivate, UserWithMembers, WebshopPreview, Webshop as WebshopStruct } from '@stamhoofd/structures';
4
4
  import { Sorter } from '@stamhoofd/utility';
5
5
 
6
6
  import { SQL } from '@stamhoofd/sql';
7
7
  import { Formatter } from '@stamhoofd/utility';
8
8
  import { Context } from './Context';
9
+ import { BalanceItemService } from '../services/BalanceItemService';
9
10
 
10
11
  /**
11
12
  * Builds authenticated structures for the current user
@@ -101,7 +102,7 @@ export class AuthenticatedStructures {
101
102
  return structs;
102
103
  }
103
104
 
104
- static async organizationRegistrationPeriods(organizationRegistrationPeriods: OrganizationRegistrationPeriod[], periods?: RegistrationPeriod[]) {
105
+ static async organizationRegistrationPeriods(organizationRegistrationPeriods: OrganizationRegistrationPeriod[], periods?: RegistrationPeriod[], options?: { forceGroupIds?: string[] }) {
105
106
  if (organizationRegistrationPeriods.length === 0) {
106
107
  return [];
107
108
  }
@@ -114,7 +115,10 @@ export class AuthenticatedStructures {
114
115
 
115
116
  const organizationIds = Formatter.uniqueArray(organizationRegistrationPeriods.map(r => r.organizationId));
116
117
 
117
- const groupIds = Formatter.uniqueArray(organizationRegistrationPeriods.flatMap(p => p.settings.categories.flatMap(c => c.groupIds)));
118
+ const groupIds = Formatter.uniqueArray([
119
+ ...organizationRegistrationPeriods.flatMap(p => p.settings.categories.flatMap(c => c.groupIds)),
120
+ ...(options?.forceGroupIds ?? []),
121
+ ]);
118
122
 
119
123
  let groups: Group[] = [];
120
124
 
@@ -391,6 +395,10 @@ export class AuthenticatedStructures {
391
395
  const organizations = new Map<string, Organization>();
392
396
 
393
397
  const registrationIds = Formatter.uniqueArray(members.flatMap(m => m.registrations.map(r => r.id)));
398
+
399
+ if (Context.organization) {
400
+ await BalanceItemService.flushCaches(Context.organization.id);
401
+ }
394
402
  const balances = await CachedBalance.getForObjects(registrationIds, null);
395
403
 
396
404
  if (includeUser) {
@@ -550,6 +558,38 @@ export class AuthenticatedStructures {
550
558
  return (await this.eventNotifications([eventNotification]))[0];
551
559
  }
552
560
 
561
+ static async registrationsBlob(registrationData: {
562
+ memberId: string;
563
+ id: string;
564
+ }[], members: MemberWithRegistrations[], includeContextOrganization = false, includeUser?: User): Promise<RegistrationsBlob> {
565
+ const membersBlob = await this.membersBlob(members, includeContextOrganization, includeUser);
566
+
567
+ const memberBlobs = membersBlob.members;
568
+
569
+ const registrationWithMemberBlobs = registrationData.map(({ id, memberId }) => {
570
+ const memberBlob = memberBlobs.find(m => m.id === memberId);
571
+ if (!memberBlob) {
572
+ throw new Error('Member not found');
573
+ }
574
+
575
+ const registration = memberBlob.registrations.find(r => r.id === id);
576
+ if (!registration) {
577
+ throw new Error('Registration not found');
578
+ }
579
+
580
+ return RegistrationWithMemberBlob.create({
581
+ ...registration,
582
+ balances: memberBlob.registrations.find(r => r.id === registration.id)?.balances ?? [],
583
+ member: memberBlob,
584
+ });
585
+ });
586
+
587
+ return RegistrationsBlob.create({
588
+ registrations: registrationWithMemberBlobs,
589
+ organizations: membersBlob.organizations,
590
+ });
591
+ }
592
+
553
593
  static async eventNotifications(eventNotifications: EventNotification[]): Promise<EventNotificationStruct[]> {
554
594
  if (eventNotifications.length === 0) {
555
595
  return [];
@@ -106,8 +106,17 @@ export class ContextInstance {
106
106
  });
107
107
  }
108
108
 
109
- static async start<T>(request: Request, handler: () => Promise<T>): Promise<T> {
109
+ static getContextFromRequest(request: Request): ContextInstance {
110
+ if ((request as any)._context) {
111
+ return (request as any)._context as ContextInstance;
112
+ }
110
113
  const context = new ContextInstance(request);
114
+ (request as any)._context = context;
115
+ return context;
116
+ }
117
+
118
+ static async start<T>(request: Request, handler: () => Promise<T>): Promise<T> {
119
+ const context = this.getContextFromRequest(request);
111
120
 
112
121
  return await this.asyncLocalStorage.run(context, async () => {
113
122
  return await handler();
@@ -223,6 +232,11 @@ export class ContextInstance {
223
232
  const token = await Token.getByAccessToken(accessToken, true);
224
233
 
225
234
  if (!token || (this.organization && token.user.organizationId !== null && token.user.organizationId !== this.organization.id) || (!this.organization && token.user.organizationId)) {
235
+ if (token?.user) {
236
+ console.log(
237
+ 'Failed auth: ' + token?.user.email + ' (' + token?.user.id + ')',
238
+ );
239
+ }
226
240
  throw new SimpleError({
227
241
  code: 'invalid_access_token',
228
242
  message: 'The access token is invalid',
@@ -232,6 +246,11 @@ export class ContextInstance {
232
246
  }
233
247
 
234
248
  if (token.isAccessTokenExpired()) {
249
+ if (token?.user) {
250
+ console.log(
251
+ 'Failed auth: ' + token?.user.email + ' (' + token?.user.id + ')',
252
+ );
253
+ }
235
254
  throw new SimpleError({
236
255
  code: 'expired_access_token',
237
256
  message: 'The access token is expired',
@@ -241,6 +260,11 @@ export class ContextInstance {
241
260
  }
242
261
 
243
262
  if (!token.user.hasAccount() && !allowWithoutAccount) {
263
+ if (token?.user) {
264
+ console.log(
265
+ 'Failed auth: ' + token?.user.email + ' (' + token?.user.id + ')',
266
+ );
267
+ }
244
268
  throw new SimpleError({
245
269
  code: 'not_activated',
246
270
  message: 'This user is not yet activated',
@@ -257,6 +281,10 @@ export class ContextInstance {
257
281
  const user = token.user;
258
282
  this.user = user;
259
283
 
284
+ console.log(
285
+ 'Auth: ' + user.email + ' (' + user.id + ')',
286
+ );
287
+
260
288
  // Load member of user
261
289
  // todo
262
290
 
@@ -1,7 +1,8 @@
1
1
  import { I18n } from '@stamhoofd/backend-i18n';
2
+ import { BalanceItemService } from '../services/BalanceItemService';
2
3
  import { FileSignService } from '../services/FileSignService';
3
- import { Context, ContextInstance } from './Context';
4
4
  import { MemberRecordStore } from '../services/MemberRecordStore';
5
+ import { ContextInstance } from './Context';
5
6
 
6
7
  export class GlobalHelper {
7
8
  static async load() {
@@ -9,6 +10,7 @@ export class GlobalHelper {
9
10
  this.loadGlobalTranslateFunction();
10
11
  await FileSignService.load();
11
12
  MemberRecordStore.init();
13
+ BalanceItemService.listen();
12
14
  }
13
15
 
14
16
  private static loadGlobalTranslateFunction() {
@@ -0,0 +1,219 @@
1
+ import { GroupedThrottledQueue } from './GroupedThrottledQueue';
2
+
3
+ describe('GroupedThrottledQueue', () => {
4
+ // Mock timers for controlling setTimeout
5
+ beforeEach(() => {
6
+ jest.useFakeTimers();
7
+ });
8
+
9
+ afterEach(() => {
10
+ jest.useRealTimers();
11
+ });
12
+
13
+ afterAll(() => {
14
+ jest.clearAllMocks();
15
+ });
16
+
17
+ it('should create an instance with the provided handler', () => {
18
+ const handler = jest.fn();
19
+ const queue = new GroupedThrottledQueue(handler);
20
+
21
+ expect(queue.handler).toBe(handler);
22
+ expect(queue.queues.size).toBe(0);
23
+ });
24
+
25
+ it('should call handler when processing items from a group', async () => {
26
+ const handler = jest.fn().mockResolvedValue(undefined);
27
+ const queue = new GroupedThrottledQueue<number>(handler);
28
+
29
+ queue.addItem('group1', 1);
30
+ await queue.flushAndWait();
31
+
32
+ expect(handler).toHaveBeenCalledWith('group1', [1]);
33
+ });
34
+
35
+ it('should process items from different groups separately', async () => {
36
+ const handler = jest.fn().mockResolvedValue(undefined);
37
+ const queue = new GroupedThrottledQueue<number>(handler);
38
+
39
+ queue.addItem('group1', 1);
40
+ queue.addItem('group2', 2);
41
+
42
+ await queue.flushAndWait();
43
+
44
+ expect(handler).toHaveBeenCalledWith('group1', [1]);
45
+ expect(handler).toHaveBeenCalledWith('group2', [2]);
46
+ expect(handler).toHaveBeenCalledTimes(2);
47
+ });
48
+
49
+ it('should batch items from the same group', async () => {
50
+ const handler = jest.fn().mockResolvedValue(undefined);
51
+ const queue = new GroupedThrottledQueue<number>(handler);
52
+
53
+ queue.addItems('group1', [1, 2, 3]);
54
+
55
+ await queue.flushAndWait();
56
+
57
+ expect(handler).toHaveBeenCalledTimes(1);
58
+ expect(handler).toHaveBeenCalledWith('group1', [1, 2, 3]);
59
+ });
60
+
61
+ it('should flush only the specified group', async () => {
62
+ const handler = jest.fn().mockResolvedValue(undefined);
63
+ const queue = new GroupedThrottledQueue<number>(handler);
64
+
65
+ queue.addItem('group1', 1);
66
+ queue.addItem('group2', 2);
67
+
68
+ await queue.flushGroupAndWait('group1');
69
+
70
+ expect(handler).toHaveBeenCalledTimes(1);
71
+ expect(handler).toHaveBeenCalledWith('group1', [1]);
72
+
73
+ // Flush the remaining group
74
+ await queue.flushGroupAndWait('group2');
75
+ expect(handler).toHaveBeenCalledTimes(2);
76
+ expect(handler).toHaveBeenCalledWith('group2', [2]);
77
+ });
78
+
79
+ it('should remove the group when all items are processed', async () => {
80
+ const handler = jest.fn().mockResolvedValue(undefined);
81
+ const queue = new GroupedThrottledQueue<number>(handler);
82
+
83
+ queue.addItem('group1', 1);
84
+
85
+ expect(queue.queues.size).toBe(1);
86
+ expect(queue.queues.has('group1')).toBe(true);
87
+
88
+ await queue.flushAndWait();
89
+
90
+ // Group should be removed because the queue is empty
91
+ expect(queue.queues.size).toBe(0);
92
+ expect(queue.queues.has('group1')).toBe(false);
93
+ });
94
+
95
+ it('should handle multiple items added to the same group', async () => {
96
+ const handler = jest.fn().mockResolvedValue(undefined);
97
+ const queue = new GroupedThrottledQueue<number>(handler);
98
+
99
+ queue.addItem('group1', 1);
100
+ queue.addItem('group1', 2);
101
+ queue.addItem('group1', 3);
102
+
103
+ await queue.flushAndWait();
104
+
105
+ expect(handler).toHaveBeenCalledTimes(1);
106
+ expect(handler).toHaveBeenCalledWith('group1', expect.arrayContaining([1, 2, 3]));
107
+ });
108
+
109
+ it('should handle large batch sizes correctly', async () => {
110
+ const handler = jest.fn().mockResolvedValue(undefined);
111
+ const queue = new GroupedThrottledQueue<number>(handler);
112
+
113
+ // Create a large array of items
114
+ const items = Array.from({ length: 150 }, (_, i) => i);
115
+
116
+ queue.addItems('group1', items);
117
+
118
+ await queue.flushAndWait();
119
+
120
+ // Due to the default maxBatchSize of 100, we expect multiple handler calls
121
+ expect(handler.mock.calls.length).toBeGreaterThanOrEqual(1);
122
+
123
+ // Verify all items were processed
124
+ const processedItems = handler.mock.calls.flatMap(call => call[1]);
125
+ expect(processedItems).toHaveLength(items.length);
126
+ expect(new Set(processedItems)).toEqual(new Set(items));
127
+ });
128
+
129
+ it('should handle errors in handler without failing the queue', async () => {
130
+ const consoleErrorMock = jest.spyOn(console, 'error').mockImplementation();
131
+
132
+ const handler = jest.fn().mockImplementation((group, items) => {
133
+ if (group === 'error-group') {
134
+ throw new Error('Test error');
135
+ }
136
+ });
137
+
138
+ const queue = new GroupedThrottledQueue<number>(handler);
139
+
140
+ queue.addItem('error-group', 1);
141
+ queue.addItem('normal-group', 2);
142
+
143
+ await queue.flushAndWait();
144
+
145
+ expect(handler).toHaveBeenCalledWith('error-group', [1]);
146
+ expect(handler).toHaveBeenCalledWith('normal-group', [2]);
147
+ expect(consoleErrorMock).toHaveBeenCalled();
148
+
149
+ // Both groups should be removed because their queues are empty
150
+ expect(queue.queues.size).toBe(0);
151
+
152
+ consoleErrorMock.mockRestore();
153
+ });
154
+
155
+ it('should handle async handler functions', async () => {
156
+ let processingPromise: Promise<void> | null = null;
157
+
158
+ const handler = jest.fn().mockImplementation((group, items) => {
159
+ processingPromise = new Promise((resolve) => {
160
+ setTimeout(() => {
161
+ resolve();
162
+ }, 10);
163
+ });
164
+ return processingPromise;
165
+ });
166
+
167
+ const queue = new GroupedThrottledQueue<number>(handler);
168
+
169
+ queue.addItem('group1', 1);
170
+ const waitPromise = queue.flushAndWait();
171
+
172
+ // Check that the handler was called
173
+ expect(handler).toHaveBeenCalled();
174
+ expect(queue.queues.size).toBe(1);
175
+
176
+ // Fast-forward time to resolve the processing promise
177
+ jest.advanceTimersByTime(100);
178
+
179
+ // Wait for the processing to complete
180
+ await waitPromise;
181
+
182
+ // The group should be removed now
183
+ expect(queue.queues.size).toBe(0);
184
+ });
185
+
186
+ it('should not error when flushing non-existing groups', async () => {
187
+ const handler = jest.fn();
188
+ const queue = new GroupedThrottledQueue<number>(handler);
189
+
190
+ await expect(queue.flushGroupAndWait('non-existing')).resolves.not.toThrow();
191
+ queue.flushGroup('non-existing'); // Should not throw
192
+
193
+ expect(handler).not.toHaveBeenCalled();
194
+ });
195
+
196
+ it('should automatically flush after maxDelay', async () => {
197
+ const handler = jest.fn().mockResolvedValue(undefined);
198
+ const queue = new GroupedThrottledQueue(handler);
199
+ queue.maxDelay = 1000; // Set a max delay for the timeout
200
+
201
+ queue.addItem('group-1', 1);
202
+ expect(queue.timeout).not.toBeNull();
203
+
204
+ jest.advanceTimersByTime(500);
205
+
206
+ queue.addItem('group-2', 2);
207
+ await queue.wait();
208
+ expect(handler).not.toHaveBeenCalled();
209
+
210
+ // Fast-forward time
211
+ jest.advanceTimersByTime(500);
212
+
213
+ await queue.wait();
214
+ expect(handler).toHaveBeenCalledWith('group-1', [1]);
215
+ expect(handler).toHaveBeenCalledWith('group-2', [2]);
216
+ expect(queue.queues.size).toBe(0);
217
+ expect(queue.timeout).toBeNull();
218
+ });
219
+ });
@@ -0,0 +1,108 @@
1
+ import { ThrottledQueue } from './ThrottledQueue';
2
+
3
+ /**
4
+ * Items are added to the queue and filtered by a handler function.
5
+ * Then they are throttled per filtered group.
6
+ */
7
+ export class GroupedThrottledQueue<T> {
8
+ queues: Map<string, ThrottledQueue<T>> = new Map();
9
+ handler: (group: string, items: T[]) => Promise<void> | void;
10
+ timeout: NodeJS.Timeout | null = null;
11
+
12
+ /**
13
+ * The queue is cleared at least after this duration.
14
+ * Set to null (default) to disable the timeout.
15
+ * In milliseconds.
16
+ */
17
+ maxDelay: number | null = 10_000;
18
+
19
+ constructor(
20
+ handler: (group: string, items: T[]) => Promise<void> | void,
21
+ options: { maxDelay?: number | null } = {},
22
+ ) {
23
+ this.handler = handler;
24
+ this.maxDelay = options.maxDelay ?? 10_000;
25
+ }
26
+
27
+ addItems(group: string, items: T[]): void {
28
+ const existingQueue = this.queues.get(group);
29
+ if (existingQueue) {
30
+ existingQueue.addItems(items);
31
+ this.startTimeout();
32
+ return;
33
+ }
34
+ const newQueue = new ThrottledQueue<T>(items => this.handler(group, items), {
35
+ maxDelay: null,
36
+ emptyHandler: () => {
37
+ this.queues.delete(group);
38
+ },
39
+ });
40
+ this.queues.set(group, newQueue);
41
+ newQueue.addItems(items);
42
+ this.startTimeout();
43
+ }
44
+
45
+ addItem(group: string, item: T): void {
46
+ this.addItems(group, [item]);
47
+ }
48
+
49
+ stopTimeout() {
50
+ if (this.timeout) {
51
+ clearTimeout(this.timeout);
52
+ this.timeout = null;
53
+ }
54
+ }
55
+
56
+ startTimeout() {
57
+ if (this.timeout || this.maxDelay === null) {
58
+ // Keep existing
59
+ return;
60
+ }
61
+
62
+ this.timeout = setTimeout(() => {
63
+ this.timeout = null;
64
+ this.flushAll();
65
+ }, this.maxDelay); // Adjust the timeout duration as needed
66
+ }
67
+
68
+ flushGroup(group: string): void {
69
+ const queue = this.queues.get(group);
70
+ if (queue) {
71
+ queue.flushAll();
72
+ }
73
+ }
74
+
75
+ flushAll(): void {
76
+ for (const queue of this.queues.values()) {
77
+ queue.flushAll();
78
+ }
79
+ this.stopTimeout();
80
+ }
81
+
82
+ async waitGroup(group: string) {
83
+ const queue = this.queues.get(group);
84
+ if (queue) {
85
+ await queue.wait();
86
+ }
87
+ }
88
+
89
+ async flushGroupAndWait(group: string) {
90
+ const queue = this.queues.get(group);
91
+ if (queue) {
92
+ await queue.flushAndWait();
93
+ }
94
+ }
95
+
96
+ async flushAndWait() {
97
+ this.stopTimeout();
98
+ for (const queue of this.queues.values()) {
99
+ await queue.flushAndWait();
100
+ }
101
+ }
102
+
103
+ async wait() {
104
+ for (const queue of this.queues.values()) {
105
+ await queue.wait();
106
+ }
107
+ }
108
+ }