@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.
- package/index.ts +19 -4
- package/package.json +18 -14
- package/src/crons/amazon-ses.ts +26 -5
- package/src/crons/balance-emails.ts +18 -17
- package/src/email-recipient-loaders/registrations.ts +87 -0
- package/src/endpoints/global/addresses/SearchRegionsEndpoint.ts +5 -2
- package/src/endpoints/global/email/PatchEmailEndpoint.test.ts +40 -40
- package/src/endpoints/global/events/PatchEventNotificationsEndpoint.test.ts +28 -22
- package/src/endpoints/global/events/PatchEventsEndpoint.ts +81 -49
- package/src/endpoints/global/files/UploadFile.ts +11 -16
- package/src/endpoints/global/groups/GetGroupsEndpoint.test.ts +234 -0
- package/src/endpoints/global/groups/GetGroupsEndpoint.ts +117 -43
- package/src/endpoints/global/members/GetMembersEndpoint.test.ts +1054 -0
- package/src/endpoints/global/members/GetMembersEndpoint.ts +163 -141
- package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.test.ts +6 -6
- package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +0 -16
- package/src/endpoints/global/members/helpers/validateGroupFilter.ts +73 -0
- package/src/endpoints/global/registration/GetPaymentRegistrations.ts +1 -2
- package/src/endpoints/global/registration/GetRegistrationsCountEndpoint.ts +43 -0
- package/src/endpoints/global/registration/GetRegistrationsEndpoint.test.ts +1016 -0
- package/src/endpoints/global/registration/GetRegistrationsEndpoint.ts +234 -0
- package/src/endpoints/global/registration/PatchUserMembersEndpoint.test.ts +5 -5
- package/src/endpoints/global/registration/RegisterMembersEndpoint.test.ts +474 -554
- package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +191 -52
- package/src/endpoints/global/registration-periods/GetRegistrationPeriodsEndpoint.ts +107 -9
- package/src/endpoints/organization/dashboard/email-templates/GetEmailTemplatesEndpoint.test.ts +89 -0
- package/src/endpoints/organization/dashboard/email-templates/GetEmailTemplatesEndpoint.ts +9 -6
- package/src/endpoints/organization/dashboard/email-templates/PatchEmailTemplatesEndpoint.test.ts +88 -0
- package/src/endpoints/organization/dashboard/email-templates/PatchEmailTemplatesEndpoint.ts +0 -6
- package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +10 -6
- package/src/endpoints/organization/dashboard/payments/GetMemberBalanceEndpoint.ts +10 -25
- package/src/endpoints/organization/dashboard/payments/PatchBalanceItemsEndpoint.ts +0 -5
- package/src/endpoints/organization/dashboard/payments/PatchPaymentsEndpoint.ts +0 -5
- package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalanceEndpoint.ts +4 -0
- package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalancesEndpoint.ts +1 -0
- package/src/endpoints/organization/dashboard/registration-periods/GetOrganizationRegistrationPeriodsEndpoint.test.ts +44 -19
- package/src/endpoints/organization/dashboard/registration-periods/GetOrganizationRegistrationPeriodsEndpoint.ts +140 -25
- package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.ts +40 -10
- package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.test.ts +2 -2
- package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.test.ts +2 -2
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopEndpoint.ts +4 -1
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +2 -2
- package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +2 -2
- package/src/excel-loaders/members.ts +233 -232
- package/src/excel-loaders/payments.ts +1 -1
- package/src/excel-loaders/receivable-balances.ts +1 -1
- package/src/excel-loaders/registrations.ts +153 -0
- package/src/helpers/AdminPermissionChecker.ts +65 -37
- package/src/helpers/AuthenticatedStructures.ts +43 -3
- package/src/helpers/Context.ts +29 -1
- package/src/helpers/GlobalHelper.ts +3 -1
- package/src/helpers/GroupedThrottledQueue.test.ts +219 -0
- package/src/helpers/GroupedThrottledQueue.ts +108 -0
- package/src/helpers/LimitedFilteredRequestHelper.ts +26 -1
- package/src/helpers/MemberCharger.ts +0 -5
- package/src/helpers/MembershipCharger.ts +3 -9
- package/src/helpers/OrganizationCharger.ts +0 -5
- package/src/helpers/ThrottledQueue.test.ts +194 -0
- package/src/helpers/ThrottledQueue.ts +145 -0
- package/src/helpers/XlsxTransformerColumnHelper.ts +44 -1
- package/src/middleware/ContextMiddleware.ts +1 -1
- package/src/seeds/1728928974-update-cached-outstanding-balance-from-items.ts +2 -1
- package/src/seeds/1735577912-update-cached-outstanding-balance-from-items.ts +2 -1
- package/src/services/BalanceItemPaymentService.ts +1 -33
- package/src/services/BalanceItemService.ts +167 -48
- package/src/services/FileSignService.ts +18 -13
- package/src/services/MemberRecordStore.ts +28 -19
- package/src/services/PaymentReallocationService.test.ts +25 -14
- package/src/services/PaymentReallocationService.ts +29 -10
- package/src/services/PaymentService.ts +4 -16
- package/src/services/PlatformMembershipService.ts +8 -4
- package/src/services/RegistrationService.ts +66 -2
- package/src/sql-filters/base-registration-filter-compilers.ts +43 -0
- package/src/sql-filters/groups.ts +67 -0
- package/src/sql-filters/members.ts +33 -58
- package/src/sql-filters/organization-registration-periods.ts +8 -0
- package/src/sql-filters/registration-periods.ts +8 -0
- package/src/sql-filters/registrations.ts +11 -22
- package/src/sql-sorters/groups.ts +24 -0
- package/src/sql-sorters/organization-registration-periods.ts +24 -0
- package/src/sql-sorters/registration-periods.ts +47 -0
- package/src/sql-sorters/registrations.ts +77 -0
- package/tests/actions/patchOrganizationMember.ts +27 -0
- package/tests/actions/patchPaymentStatus.ts +45 -0
- package/tests/actions/patchUserMember.ts +27 -0
- package/tests/assertions/assertBalances.ts +49 -0
- package/tests/e2e/api-rate-limits.test.ts +5 -5
- package/tests/e2e/bundle-discounts.test.ts +4060 -0
- package/tests/e2e/charge-members.test.ts +27 -24
- package/tests/e2e/documents.test.ts +398 -0
- package/tests/e2e/register.test.ts +292 -312
- package/tests/helpers/PayconiqMocker.ts +55 -0
- package/tests/init/index.ts +5 -0
- package/tests/init/initAdmin.ts +14 -0
- package/tests/init/initBundleDiscount.ts +47 -0
- package/tests/init/initPayconiq.ts +9 -0
- package/tests/init/initPlatformAdmin.ts +13 -0
- package/tests/init/initStripe.ts +21 -0
- package/tests/jest.setup.ts +29 -0
- 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
|
-
|
|
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
|
|
68
|
-
const
|
|
69
|
-
if (
|
|
70
|
-
return await
|
|
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
|
|
89
|
-
human
|
|
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
|
|
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
|
-
|
|
684
|
+
|
|
685
|
+
return EmailTemplateStruct.allowPlatformLevel(template.type);
|
|
657
686
|
}
|
|
658
|
-
|
|
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
|
-
|
|
810
|
-
|
|
811
|
-
if (!organizationPermissions) {
|
|
843
|
+
if (!await this.hasSomeAccess(organizationId) && !this.hasSomePlatformAccess()) {
|
|
812
844
|
return false;
|
|
813
845
|
}
|
|
814
846
|
|
|
815
|
-
return
|
|
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
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
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
|
-
|
|
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,
|
|
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(
|
|
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 [];
|
package/src/helpers/Context.ts
CHANGED
|
@@ -106,8 +106,17 @@ export class ContextInstance {
|
|
|
106
106
|
});
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
static
|
|
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
|
+
}
|