@stamhoofd/backend 2.83.5 → 2.84.1
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,5 +1,6 @@
|
|
|
1
1
|
import { SimpleError } from '@simonbackx/simple-errors';
|
|
2
|
-
import {
|
|
2
|
+
import { SQLSortDefinitions } from '@stamhoofd/sql';
|
|
3
|
+
import { getSortFilter, LimitedFilteredRequest } from '@stamhoofd/structures';
|
|
3
4
|
|
|
4
5
|
export class LimitedFilteredRequestHelper {
|
|
5
6
|
static throwIfInvalidLimit({ request, maxLimit }: { request: LimitedFilteredRequest; maxLimit: number }) {
|
|
@@ -21,4 +22,28 @@ export class LimitedFilteredRequestHelper {
|
|
|
21
22
|
});
|
|
22
23
|
}
|
|
23
24
|
}
|
|
25
|
+
|
|
26
|
+
static fixInfiniteLoadingLoop<T>({ request, results, sorters }: { request: LimitedFilteredRequest; results: T[]; sorters: SQLSortDefinitions<T> }): LimitedFilteredRequest | undefined {
|
|
27
|
+
let next: LimitedFilteredRequest | undefined;
|
|
28
|
+
|
|
29
|
+
if (results.length >= request.limit) {
|
|
30
|
+
const lastObject = results[results.length - 1];
|
|
31
|
+
const nextFilter = getSortFilter(lastObject, sorters, request.sort);
|
|
32
|
+
|
|
33
|
+
next = new LimitedFilteredRequest({
|
|
34
|
+
filter: request.filter,
|
|
35
|
+
pageFilter: nextFilter,
|
|
36
|
+
sort: request.sort,
|
|
37
|
+
limit: request.limit,
|
|
38
|
+
search: request.search,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
if (JSON.stringify(nextFilter) === JSON.stringify(request.pageFilter)) {
|
|
42
|
+
console.error('Found infinite loading loop for', request);
|
|
43
|
+
next = undefined;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return next;
|
|
48
|
+
}
|
|
24
49
|
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { BalanceItem } from '@stamhoofd/models';
|
|
2
2
|
import { BalanceItemType, MemberWithRegistrationsBlob } from '@stamhoofd/structures';
|
|
3
|
-
import { BalanceItemService } from '../services/BalanceItemService';
|
|
4
3
|
|
|
5
4
|
export class MemberCharger {
|
|
6
5
|
static async chargeMany({ chargingOrganizationId, membersToCharge, price, amount, description, dueAt, createdAt }: { chargingOrganizationId: string; membersToCharge: MemberWithRegistrationsBlob[]; price: number; amount?: number; description: string; dueAt: Date | null; createdAt: Date | null }) {
|
|
@@ -15,10 +14,6 @@ export class MemberCharger {
|
|
|
15
14
|
}));
|
|
16
15
|
|
|
17
16
|
await Promise.all(balanceItems.map(balanceItem => balanceItem.save()));
|
|
18
|
-
await BalanceItem.updateOutstanding(balanceItems);
|
|
19
|
-
|
|
20
|
-
// Reallocate
|
|
21
|
-
await BalanceItemService.reallocate(balanceItems, chargingOrganizationId);
|
|
22
17
|
}
|
|
23
18
|
|
|
24
19
|
private static createBalanceItem({ price, amount, description, chargingOrganizationId, memberBeingCharged, dueAt, createdAt }: { price: number; amount?: number; description: string; chargingOrganizationId: string; memberBeingCharged: MemberWithRegistrationsBlob; dueAt: Date | null; createdAt: Date | null }): BalanceItem {
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { SimpleError } from '@simonbackx/simple-errors';
|
|
2
2
|
import { BalanceItem, Member, MemberPlatformMembership, Platform } from '@stamhoofd/models';
|
|
3
3
|
import { SQL, SQLOrderBy, SQLWhereSign } from '@stamhoofd/sql';
|
|
4
|
-
import { BalanceItemRelation, BalanceItemRelationType, BalanceItemType } from '@stamhoofd/structures';
|
|
4
|
+
import { BalanceItemRelation, BalanceItemRelationType, BalanceItemType, TranslatedString } from '@stamhoofd/structures';
|
|
5
5
|
import { Formatter } from '@stamhoofd/utility';
|
|
6
|
-
import { BalanceItemService } from '../services/BalanceItemService';
|
|
7
6
|
|
|
8
7
|
export const MembershipCharger = {
|
|
9
8
|
async charge() {
|
|
@@ -104,14 +103,14 @@ export const MembershipCharger = {
|
|
|
104
103
|
BalanceItemRelationType.Member,
|
|
105
104
|
BalanceItemRelation.create({
|
|
106
105
|
id: member.id,
|
|
107
|
-
name: member.details.name,
|
|
106
|
+
name: new TranslatedString(member.details.name),
|
|
108
107
|
}),
|
|
109
108
|
],
|
|
110
109
|
[
|
|
111
110
|
BalanceItemRelationType.MembershipType,
|
|
112
111
|
BalanceItemRelation.create({
|
|
113
112
|
id: type.id,
|
|
114
|
-
name: type.name,
|
|
113
|
+
name: new TranslatedString(type.name),
|
|
115
114
|
}),
|
|
116
115
|
],
|
|
117
116
|
]);
|
|
@@ -131,11 +130,6 @@ export const MembershipCharger = {
|
|
|
131
130
|
createdPrice += membership.price;
|
|
132
131
|
}
|
|
133
132
|
|
|
134
|
-
await BalanceItem.updateOutstanding(createdBalanceItems);
|
|
135
|
-
|
|
136
|
-
// Reallocate
|
|
137
|
-
await BalanceItemService.reallocate(createdBalanceItems, chargeVia);
|
|
138
|
-
|
|
139
133
|
if (memberships.length < chunkSize) {
|
|
140
134
|
break;
|
|
141
135
|
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { BalanceItem } from '@stamhoofd/models';
|
|
2
2
|
import { BalanceItemType, Organization as OrganizationStruct } from '@stamhoofd/structures';
|
|
3
|
-
import { BalanceItemService } from '../services/BalanceItemService';
|
|
4
3
|
|
|
5
4
|
export class OrganizationCharger {
|
|
6
5
|
static async chargeMany({ chargingOrganizationId, organizationsToCharge, price, amount, description, dueAt, createdAt }: { chargingOrganizationId: string; organizationsToCharge: OrganizationStruct[]; price: number; amount?: number; description: string; dueAt: Date | null; createdAt: Date | null }) {
|
|
@@ -15,10 +14,6 @@ export class OrganizationCharger {
|
|
|
15
14
|
}));
|
|
16
15
|
|
|
17
16
|
await Promise.all(balanceItems.map(balanceItem => balanceItem.save()));
|
|
18
|
-
await BalanceItem.updateOutstanding(balanceItems);
|
|
19
|
-
|
|
20
|
-
// Reallocate
|
|
21
|
-
await BalanceItemService.reallocate(balanceItems, chargingOrganizationId);
|
|
22
17
|
}
|
|
23
18
|
|
|
24
19
|
private static createBalanceItem({ price, amount, description, chargingOrganizationId, organizationBeingCharged, dueAt, createdAt }: { price: number; amount?: number; description: string; chargingOrganizationId: string; organizationBeingCharged: OrganizationStruct; dueAt: Date | null; createdAt: Date | null }): BalanceItem {
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { ThrottledQueue } from './ThrottledQueue';
|
|
2
|
+
|
|
3
|
+
describe('ThrottledQueue', () => {
|
|
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 ThrottledQueue(handler);
|
|
20
|
+
|
|
21
|
+
expect(queue.handler).toBe(handler);
|
|
22
|
+
expect(queue.items.size).toBe(0);
|
|
23
|
+
expect(queue.timeout).toBeNull();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should add items to the queue', () => {
|
|
27
|
+
const handler = jest.fn();
|
|
28
|
+
const queue = new ThrottledQueue(handler);
|
|
29
|
+
|
|
30
|
+
queue.addItem(1);
|
|
31
|
+
expect(queue.items.size).toBe(1);
|
|
32
|
+
expect(queue.items.has(1)).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should add multiple items to the queue', () => {
|
|
36
|
+
const handler = jest.fn();
|
|
37
|
+
const queue = new ThrottledQueue(handler);
|
|
38
|
+
|
|
39
|
+
queue.addItems([1, 2, 3]);
|
|
40
|
+
expect(queue.items.size).toBe(3);
|
|
41
|
+
expect(queue.items.has(1)).toBe(true);
|
|
42
|
+
expect(queue.items.has(2)).toBe(true);
|
|
43
|
+
expect(queue.items.has(3)).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should flush automatically when reaching maxBatchSize', async () => {
|
|
47
|
+
const handler = jest.fn().mockResolvedValue(undefined);
|
|
48
|
+
const queue = new ThrottledQueue(handler);
|
|
49
|
+
queue.maxBatchSize = 3;
|
|
50
|
+
|
|
51
|
+
// Spy on flushAll to check if it's called
|
|
52
|
+
const flushAllSpy = jest.spyOn(queue, 'flushAll');
|
|
53
|
+
|
|
54
|
+
queue.addItems([1, 2]);
|
|
55
|
+
expect(flushAllSpy).not.toHaveBeenCalled();
|
|
56
|
+
|
|
57
|
+
queue.addItem(3);
|
|
58
|
+
expect(flushAllSpy).toHaveBeenCalled();
|
|
59
|
+
|
|
60
|
+
// Wait for all promises to resolve
|
|
61
|
+
await queue.wait();
|
|
62
|
+
|
|
63
|
+
expect(handler).toHaveBeenCalledWith([1, 2, 3]);
|
|
64
|
+
expect(queue.items.size).toBe(0);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should process items in batches when exceeding maxBatchSize', async () => {
|
|
68
|
+
const handler = jest.fn().mockResolvedValue(undefined);
|
|
69
|
+
const queue = new ThrottledQueue(handler);
|
|
70
|
+
queue.maxBatchSize = 3;
|
|
71
|
+
|
|
72
|
+
const items = [1, 2, 3, 4, 5, 6, 7];
|
|
73
|
+
queue.addItems(items);
|
|
74
|
+
await queue.wait();
|
|
75
|
+
|
|
76
|
+
// Expecting multiple calls with batches of appropriate size
|
|
77
|
+
expect(handler).toHaveBeenCalledTimes(3);
|
|
78
|
+
|
|
79
|
+
// Check if all items were processed
|
|
80
|
+
const processedItems = new Set([
|
|
81
|
+
...handler.mock.calls[0][0],
|
|
82
|
+
...handler.mock.calls[1][0],
|
|
83
|
+
...handler.mock.calls[2][0],
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
expect(processedItems.size).toBe(items.length);
|
|
87
|
+
items.forEach(item => expect(processedItems.has(item)).toBe(true));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should call emptyHandler when queue becomes empty', async () => {
|
|
91
|
+
const handler = jest.fn().mockResolvedValue(undefined);
|
|
92
|
+
const queue = new ThrottledQueue(handler);
|
|
93
|
+
const emptyHandler = jest.fn();
|
|
94
|
+
queue.emptyHandler = emptyHandler;
|
|
95
|
+
|
|
96
|
+
queue.maxBatchSize = 3;
|
|
97
|
+
queue.addItems([1, 2, 3]);
|
|
98
|
+
await queue.wait();
|
|
99
|
+
|
|
100
|
+
expect(emptyHandler).toHaveBeenCalled();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('should not call emptyHandler when queue is filled during processing', async () => {
|
|
104
|
+
const handler = jest.fn().mockResolvedValue(undefined);
|
|
105
|
+
const queue = new ThrottledQueue(handler);
|
|
106
|
+
const emptyHandler = jest.fn();
|
|
107
|
+
queue.emptyHandler = emptyHandler;
|
|
108
|
+
|
|
109
|
+
queue.maxBatchSize = 3;
|
|
110
|
+
queue.addItems([1, 2, 3]);
|
|
111
|
+
// flush happened automatically above
|
|
112
|
+
queue.addItems([1]);
|
|
113
|
+
await queue.wait();
|
|
114
|
+
|
|
115
|
+
expect(emptyHandler).not.toHaveBeenCalled();
|
|
116
|
+
|
|
117
|
+
await queue.flushAndWait();
|
|
118
|
+
expect(emptyHandler).toHaveBeenCalled();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('should not fail if handler throws an error', async () => {
|
|
122
|
+
const error = new Error('Test error');
|
|
123
|
+
const handler = jest.fn().mockRejectedValue(error);
|
|
124
|
+
const queue = new ThrottledQueue(handler);
|
|
125
|
+
|
|
126
|
+
// Spy on console.error
|
|
127
|
+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
128
|
+
|
|
129
|
+
queue.addItem(1);
|
|
130
|
+
await queue.flushAndWait();
|
|
131
|
+
|
|
132
|
+
expect(handler).toHaveBeenCalledWith([1]);
|
|
133
|
+
expect(consoleErrorSpy).toHaveBeenCalled();
|
|
134
|
+
expect(consoleErrorSpy.mock.calls[0][0]).toContain('Error processing batch in ThrottledQueue:');
|
|
135
|
+
|
|
136
|
+
consoleErrorSpy.mockRestore();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('should handle flushAndWait when queue is empty', async () => {
|
|
140
|
+
const handler = jest.fn();
|
|
141
|
+
const queue = new ThrottledQueue(handler);
|
|
142
|
+
|
|
143
|
+
await queue.flushAndWait();
|
|
144
|
+
expect(handler).not.toHaveBeenCalled();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('should allow adding items while processing', async () => {
|
|
148
|
+
// Setup a handler that adds more items to the queue when called
|
|
149
|
+
const queue = new ThrottledQueue(async (items: number[]) => {
|
|
150
|
+
if (items.includes(1)) {
|
|
151
|
+
queue.addItem(4);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
queue.addItems([1, 2, 3]);
|
|
156
|
+
await queue.flushAndWait();
|
|
157
|
+
|
|
158
|
+
expect(queue.items.size).toBe(1);
|
|
159
|
+
expect(queue.items.has(4)).toBe(true);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('should handle startTimeout and stopTimeout correctly', async () => {
|
|
163
|
+
const handler = jest.fn();
|
|
164
|
+
const queue = new ThrottledQueue(handler);
|
|
165
|
+
queue.maxDelay = 1000; // Set a max delay for the timeout
|
|
166
|
+
|
|
167
|
+
queue.addItem(1);
|
|
168
|
+
expect(queue.timeout).not.toBeNull();
|
|
169
|
+
|
|
170
|
+
await queue.flushAndWait();
|
|
171
|
+
expect(queue.timeout).toBeNull();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('should automatically flush after maxDelay', async () => {
|
|
175
|
+
const handler = jest.fn().mockResolvedValue(undefined);
|
|
176
|
+
const queue = new ThrottledQueue(handler);
|
|
177
|
+
queue.maxDelay = 1000; // Set a max delay for the timeout
|
|
178
|
+
|
|
179
|
+
queue.addItem(1);
|
|
180
|
+
expect(queue.timeout).not.toBeNull();
|
|
181
|
+
|
|
182
|
+
jest.advanceTimersByTime(500);
|
|
183
|
+
await queue.wait();
|
|
184
|
+
expect(handler).not.toHaveBeenCalled();
|
|
185
|
+
|
|
186
|
+
// Fast-forward time
|
|
187
|
+
jest.advanceTimersByTime(500);
|
|
188
|
+
|
|
189
|
+
await queue.wait();
|
|
190
|
+
expect(handler).toHaveBeenCalledWith([1]);
|
|
191
|
+
expect(queue.items.size).toBe(0);
|
|
192
|
+
expect(queue.timeout).toBeNull();
|
|
193
|
+
});
|
|
194
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Define an expensive action that needs to run on a list of items.
|
|
3
|
+
* Instead of running the action per item, it will call it on a batch of items or if a certain time has passed.
|
|
4
|
+
*
|
|
5
|
+
* Note that the handler can be called in parallel in some situations.
|
|
6
|
+
*/
|
|
7
|
+
export class ThrottledQueue<T> {
|
|
8
|
+
items: Set<T> = new Set<T>();
|
|
9
|
+
timeout: NodeJS.Timeout | null = null;
|
|
10
|
+
|
|
11
|
+
maxBatchSize = 100;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The queue is cleared at least after this duration.
|
|
15
|
+
* Set to null (default) to disable the timeout.
|
|
16
|
+
* In milliseconds.
|
|
17
|
+
*/
|
|
18
|
+
maxDelay: number | null = 10_000;
|
|
19
|
+
|
|
20
|
+
handler: (items: T[]) => Promise<void> | void;
|
|
21
|
+
emptyHandler?: () => void;
|
|
22
|
+
|
|
23
|
+
constructor(handler: (items: T[]) => Promise<void> | void, options: { maxBatchSize?: number; maxDelay?: number | null; emptyHandler?: () => void } = {}) {
|
|
24
|
+
this.handler = handler;
|
|
25
|
+
this.maxBatchSize = options.maxBatchSize ?? 100;
|
|
26
|
+
this.maxDelay = options.maxDelay ?? 10_000;
|
|
27
|
+
this.emptyHandler = options.emptyHandler;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
addItems(items: T[]): void {
|
|
31
|
+
for (const item of items) {
|
|
32
|
+
this.items.add(item);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (this.items.size >= this.maxBatchSize) {
|
|
36
|
+
this.flushAll();
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
this.startTimeout();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
addItem(item: T): void {
|
|
43
|
+
this.items.add(item);
|
|
44
|
+
|
|
45
|
+
if (this.items.size >= this.maxBatchSize) {
|
|
46
|
+
this.flushAll();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
this.startTimeout();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
stopTimeout() {
|
|
53
|
+
if (this.timeout) {
|
|
54
|
+
clearTimeout(this.timeout);
|
|
55
|
+
this.timeout = null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
startTimeout() {
|
|
60
|
+
if (this.timeout || this.maxDelay === null) {
|
|
61
|
+
// Keep existing
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
this.timeout = setTimeout(() => {
|
|
66
|
+
this.timeout = null;
|
|
67
|
+
this.flushAll();
|
|
68
|
+
}, this.maxDelay); // Adjust the timeout duration as needed
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
pendingFlushes: Set<Promise<void>> = new Set<Promise<void>>();
|
|
72
|
+
|
|
73
|
+
private async process(items: T[]) {
|
|
74
|
+
// Calculate an efficient batch size (not simply using maxBatchSize)
|
|
75
|
+
const batchCount = Math.ceil(items.length / this.maxBatchSize);
|
|
76
|
+
if (batchCount === 0) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const batchSize = Math.ceil(items.length / batchCount);
|
|
80
|
+
|
|
81
|
+
for (let i = 0; i < items.length; i += batchSize) {
|
|
82
|
+
// Avoid cloning the array if the batch size is larger than the remaining items
|
|
83
|
+
const batch = batchSize < items.length ? items.slice(i, i + batchSize) : items;
|
|
84
|
+
try {
|
|
85
|
+
await this.handler(batch);
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
console.error('Error processing batch in ThrottledQueue:', error);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
processErrorHandler(error: Error) {
|
|
94
|
+
console.error('Error in ThrottledQueue flush:', error);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Flushes all items in the queue.
|
|
99
|
+
* Items that are added while processing will not be processed automatically, but rather when the tiemout is reached or the maximum is reached.
|
|
100
|
+
*/
|
|
101
|
+
flushAll() {
|
|
102
|
+
if (this.items.size === 0) {
|
|
103
|
+
// Nothing to flush or clear
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Move items, so we can continue adding items while we are processing
|
|
108
|
+
const items = [...this.items];
|
|
109
|
+
this.items.clear();
|
|
110
|
+
|
|
111
|
+
const flush = this.process(items)
|
|
112
|
+
.catch(this.processErrorHandler)
|
|
113
|
+
.finally(() => {
|
|
114
|
+
this.pendingFlushes.delete(flush);
|
|
115
|
+
|
|
116
|
+
if (this.pendingFlushes.size === 0 && this.items.size === 0) {
|
|
117
|
+
// Call empty handler to signal that the queue is empty
|
|
118
|
+
this.stopTimeout();
|
|
119
|
+
|
|
120
|
+
if (this.emptyHandler) {
|
|
121
|
+
this.emptyHandler();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
this.pendingFlushes.add(flush);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async wait() {
|
|
130
|
+
if (this.pendingFlushes.size === 0) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Wait for all pending flushes to complete
|
|
135
|
+
await Promise.all(this.pendingFlushes);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Note: this won't wait on new items that are added while flushing.
|
|
140
|
+
*/
|
|
141
|
+
async flushAndWait() {
|
|
142
|
+
this.flushAll();
|
|
143
|
+
await this.wait();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { XlsxTransformerColumn } from '@stamhoofd/excel-writer';
|
|
1
|
+
import { isXlsxTransformerConcreteColumn, XlsxTransformerColumn, XlsxTransformerConcreteColumn } from '@stamhoofd/excel-writer';
|
|
2
2
|
import { Address, CountryHelper, Parent, ParentTypeHelper, PlatformMember, RecordAnswer, RecordCategory, RecordSettings, RecordType } from '@stamhoofd/structures';
|
|
3
3
|
|
|
4
4
|
export class XlsxTransformerColumnHelper {
|
|
@@ -239,4 +239,47 @@ export class XlsxTransformerColumnHelper {
|
|
|
239
239
|
},
|
|
240
240
|
};
|
|
241
241
|
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Makes it possible to reuse an XlsxTransformerColumn, for example member columns exist, PlatformRegistration has
|
|
245
|
+
* a property member, so we can reuse the member columns for PlatformRegistration.
|
|
246
|
+
* @param param0
|
|
247
|
+
* @returns
|
|
248
|
+
*/
|
|
249
|
+
private static transformConcreteColumnForProperty<T, O>({ column, key, getPropertyValue }: { column: XlsxTransformerConcreteColumn<T>; key: string; getPropertyValue: (object: O) => T }): XlsxTransformerConcreteColumn<O> {
|
|
250
|
+
return {
|
|
251
|
+
...column,
|
|
252
|
+
id: `${key}.${column.id}`,
|
|
253
|
+
getValue: (object: O) => column.getValue(getPropertyValue(object)),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Makes it possible to reuse an XlsxTransformerColumn, for example member columns exist, PlatformRegistration has
|
|
259
|
+
* a property member, so we can reuse the member columns for PlatformRegistration.
|
|
260
|
+
* @param param0
|
|
261
|
+
* @returns
|
|
262
|
+
*/
|
|
263
|
+
static transformColumnForProperty<T, O>({ column, key, getPropertyValue }: { column: XlsxTransformerColumn<T>; key: string; getPropertyValue: (object: O) => T }): XlsxTransformerColumn<O> {
|
|
264
|
+
if (isXlsxTransformerConcreteColumn(column)) {
|
|
265
|
+
return this.transformConcreteColumnForProperty({ column, key, getPropertyValue });
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
match: (id: string) => {
|
|
270
|
+
if (!id.startsWith(key + '.')) {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const subId = id.substring(key.length + 1);
|
|
275
|
+
const subColumns = column.match(subId);
|
|
276
|
+
|
|
277
|
+
if (!subColumns) {
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return subColumns.map(column => XlsxTransformerColumnHelper.transformConcreteColumnForProperty({ column, key, getPropertyValue }));
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
}
|
|
242
285
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Migration } from '@simonbackx/simple-database';
|
|
2
2
|
import { logger } from '@simonbackx/simple-logging';
|
|
3
3
|
import { BalanceItem } from '@stamhoofd/models';
|
|
4
|
+
import { BalanceItemService } from '../services/BalanceItemService';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* This migration is required to keep '1733994455-balance-item-status-open' working
|
|
@@ -16,7 +17,7 @@ export default new Migration(async () => {
|
|
|
16
17
|
|
|
17
18
|
await logger.setContext({ tags: ['silent-seed', 'seed'] }, async () => {
|
|
18
19
|
for await (const items of BalanceItem.select().limit(1000).allBatched()) {
|
|
19
|
-
await
|
|
20
|
+
await BalanceItemService.updatePaidAndPending(items);
|
|
20
21
|
|
|
21
22
|
c += items.length;
|
|
22
23
|
process.stdout.write('.');
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Migration } from '@simonbackx/simple-database';
|
|
2
2
|
import { logger } from '@simonbackx/simple-logging';
|
|
3
3
|
import { BalanceItem } from '@stamhoofd/models';
|
|
4
|
+
import { BalanceItemService } from '../services/BalanceItemService';
|
|
4
5
|
|
|
5
6
|
export default new Migration(async () => {
|
|
6
7
|
if (STAMHOOFD.environment == 'test') {
|
|
@@ -13,7 +14,7 @@ export default new Migration(async () => {
|
|
|
13
14
|
|
|
14
15
|
await logger.setContext({ tags: ['silent-seed', 'seed'] }, async () => {
|
|
15
16
|
for await (const items of BalanceItem.select().limit(1000).allBatched()) {
|
|
16
|
-
await
|
|
17
|
+
await BalanceItemService.updatePaidAndPending(items);
|
|
17
18
|
|
|
18
19
|
c += items.length;
|
|
19
20
|
process.stdout.write('.');
|
|
@@ -1,44 +1,12 @@
|
|
|
1
1
|
import { ManyToOneRelation } from '@simonbackx/simple-database';
|
|
2
2
|
import { BalanceItemPayment, Organization } from '@stamhoofd/models';
|
|
3
|
-
import { BalanceItemStatus } from '@stamhoofd/structures';
|
|
4
3
|
import { BalanceItemService } from './BalanceItemService';
|
|
5
4
|
|
|
6
5
|
type Loaded<T> = (T) extends ManyToOneRelation<infer Key, infer Model> ? Record<Key, Model> : never;
|
|
7
6
|
|
|
8
7
|
export const BalanceItemPaymentService = {
|
|
9
8
|
async markPaid(balanceItemPayment: BalanceItemPayment & Loaded<typeof BalanceItemPayment.balanceItem> & Loaded<typeof BalanceItemPayment.payment>, organization: Organization) {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
// Update cached amountPaid of the balance item (balanceItemPayment will get overwritten later, but we need it to calculate the status)
|
|
13
|
-
balanceItemPayment.balanceItem.pricePaid += balanceItemPayment.price;
|
|
14
|
-
|
|
15
|
-
if (balanceItemPayment.balanceItem.status === BalanceItemStatus.Hidden && balanceItemPayment.balanceItem.pricePaid !== 0) {
|
|
16
|
-
balanceItemPayment.balanceItem.status = BalanceItemStatus.Due;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
await balanceItemPayment.balanceItem.save();
|
|
20
|
-
const isPaid = balanceItemPayment.balanceItem.isPaid;
|
|
21
|
-
|
|
22
|
-
// Do logic of balance item
|
|
23
|
-
if (isPaid && !wasPaid && balanceItemPayment.price >= 0 && balanceItemPayment.balanceItem.status === BalanceItemStatus.Due) {
|
|
24
|
-
// Only call markPaid once (if it wasn't (partially) paid before)
|
|
25
|
-
await BalanceItemService.markPaid(balanceItemPayment.balanceItem, balanceItemPayment.payment, organization);
|
|
26
|
-
}
|
|
27
|
-
else {
|
|
28
|
-
await BalanceItemService.markUpdated(balanceItemPayment.balanceItem, balanceItemPayment.payment, organization);
|
|
29
|
-
}
|
|
30
|
-
},
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Safe method to correct balance items that missed a markPaid call, but avoid double marking an order as valid.
|
|
34
|
-
*/
|
|
35
|
-
async markPaidRepeated(balanceItemPayment: BalanceItemPayment & Loaded<typeof BalanceItemPayment.balanceItem> & Loaded<typeof BalanceItemPayment.payment>, organization: Organization) {
|
|
36
|
-
const isPaid = balanceItemPayment.balanceItem.isPaid;
|
|
37
|
-
|
|
38
|
-
// Do logic of balance item
|
|
39
|
-
if (isPaid && balanceItemPayment.price >= 0 && balanceItemPayment.balanceItem.status === BalanceItemStatus.Due) {
|
|
40
|
-
await BalanceItemService.markPaidRepeated(balanceItemPayment.balanceItem, balanceItemPayment.payment, organization);
|
|
41
|
-
}
|
|
9
|
+
await BalanceItemService.markPaid(balanceItemPayment.balanceItem, balanceItemPayment.payment, organization);
|
|
42
10
|
},
|
|
43
11
|
|
|
44
12
|
/**
|