@stamhoofd/backend 2.114.1 → 2.115.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 CHANGED
@@ -1,5 +1,6 @@
1
1
  import backendEnv from '@stamhoofd/backend-env';
2
2
 
3
+ process.title = 'stamhoofd-api';
3
4
  backendEnv.load({ service: 'api' }).catch((error) => {
4
5
  console.error('Failed to load environment:', error);
5
6
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stamhoofd/backend",
3
- "version": "2.114.1",
3
+ "version": "2.115.0",
4
4
  "main": "./dist/index.js",
5
5
  "exports": {
6
6
  ".": {
@@ -19,6 +19,7 @@
19
19
  "license": "UNLICENCED",
20
20
  "scripts": {
21
21
  "dev": "wait-on ../../shared/middleware/dist/index.js && concurrently -r 'yarn -s build --watch --preserveWatchOutput' \"yarn -s dev:watch\"",
22
+ "dev:full": "yarn -s dev",
22
23
  "dev:watch": "wait-on ./dist/index.js && nodemon --quiet --inspect=5858 --watch dist --watch '../../../shared/*/dist/' --watch '../../shared/*/dist/' --ext .ts,.json,.sql,.js --delay 2000ms --exec 'node --enable-source-maps ./dist/index.js' --signal SIGTERM",
23
24
  "dev:backend": "yarn -s dev",
24
25
  "build": "rm -rf ./dist/src/migrations && rm -rf ./dist/src/seeds && yarn -s copy-assets && tsc -b",
@@ -54,14 +55,14 @@
54
55
  "@simonbackx/simple-encoding": "2.23.1",
55
56
  "@simonbackx/simple-endpoints": "1.20.1",
56
57
  "@simonbackx/simple-logging": "^1.0.1",
57
- "@stamhoofd/backend-i18n": "2.114.1",
58
- "@stamhoofd/backend-middleware": "2.114.1",
59
- "@stamhoofd/email": "2.114.1",
60
- "@stamhoofd/models": "2.114.1",
61
- "@stamhoofd/queues": "2.114.1",
62
- "@stamhoofd/sql": "2.114.1",
63
- "@stamhoofd/structures": "2.114.1",
64
- "@stamhoofd/utility": "2.114.1",
58
+ "@stamhoofd/backend-i18n": "2.115.0",
59
+ "@stamhoofd/backend-middleware": "2.115.0",
60
+ "@stamhoofd/email": "2.115.0",
61
+ "@stamhoofd/models": "2.115.0",
62
+ "@stamhoofd/queues": "2.115.0",
63
+ "@stamhoofd/sql": "2.115.0",
64
+ "@stamhoofd/structures": "2.115.0",
65
+ "@stamhoofd/utility": "2.115.0",
65
66
  "archiver": "^7.0.1",
66
67
  "axios": "^1.13.2",
67
68
  "cookie": "^0.7.0",
@@ -79,5 +80,5 @@
79
80
  "publishConfig": {
80
81
  "access": "public"
81
82
  },
82
- "gitHead": "0394c203f4133023b2a813d96554ea8b4a0a73d8"
83
+ "gitHead": "b68983abee843a6d4f4460b7818d66a8780587cc"
83
84
  }
package/src/boot.ts CHANGED
@@ -10,17 +10,19 @@ import { SimpleError } from '@simonbackx/simple-errors';
10
10
  import { startCrons, stopCrons, waitForCrons } from '@stamhoofd/crons';
11
11
  import { Platform } from '@stamhoofd/models';
12
12
  import { QueueHandler } from '@stamhoofd/queues';
13
- import { resumeEmails } from './helpers/EmailResumer';
14
- import { GlobalHelper } from './helpers/GlobalHelper';
15
- import { SetupStepUpdater } from './helpers/SetupStepUpdater';
16
- import { ContextMiddleware } from './middleware/ContextMiddleware';
17
- import { AuditLogService } from './services/AuditLogService';
18
- import { BalanceItemService } from './services/BalanceItemService';
19
- import { DocumentService } from './services/DocumentService';
20
- import { FileSignService } from './services/FileSignService';
21
- import { PlatformMembershipService } from './services/PlatformMembershipService';
22
- import { UitpasService } from './services/uitpas/UitpasService';
23
- import { UniqueUserService } from './services/UniqueUserService';
13
+ import { resumeEmails } from './helpers/EmailResumer.js';
14
+ import { GlobalHelper } from './helpers/GlobalHelper.js';
15
+ import { SetupStepUpdater } from './helpers/SetupStepUpdater.js';
16
+ import { ContextMiddleware } from './middleware/ContextMiddleware.js';
17
+ import { AuditLogService } from './services/AuditLogService.js';
18
+ import { BalanceItemService } from './services/BalanceItemService.js';
19
+ import { DocumentService } from './services/DocumentService.js';
20
+ import { FileSignService } from './services/FileSignService.js';
21
+ import { PlatformMembershipService } from './services/PlatformMembershipService.js';
22
+ import { UitpasService } from './services/uitpas/UitpasService.js';
23
+ import { UniqueUserService } from './services/UniqueUserService.js';
24
+ import { CpuService } from './services/CpuService.js';
25
+ import { SQLLogger } from '@stamhoofd/sql';
24
26
 
25
27
  process.on('unhandledRejection', (error: Error) => {
26
28
  console.error('unhandledRejection');
@@ -123,16 +125,16 @@ export const boot = async (options: { killProcess: boolean }) => {
123
125
  productionLog('Loading loaders...');
124
126
 
125
127
  // Register Excel loaders
126
- await import('./excel-loaders');
128
+ await import('./excel-loaders/index.js');
127
129
 
128
130
  // Register Email Recipient loaders
129
- await import('./email-recipient-loaders/members');
130
- await import('./email-recipient-loaders/registrations');
131
- await import('./email-recipient-loaders/orders');
132
- await import('./email-recipient-loaders/receivable-balances');
133
- await import('./excel-loaders/registrations');
134
- await import('./email-recipient-loaders/documents');
135
- await import ('./email-recipient-loaders/payments');
131
+ await import('./email-recipient-loaders/members.js');
132
+ await import('./email-recipient-loaders/registrations.js');
133
+ await import('./email-recipient-loaders/orders.js');
134
+ await import('./email-recipient-loaders/receivable-balances.js');
135
+ await import('./excel-loaders/registrations.js');
136
+ await import('./email-recipient-loaders/documents.js');
137
+ await import ('./email-recipient-loaders/payments.js');
136
138
 
137
139
  productionLog('Opening port...');
138
140
  routerServer.listen(STAMHOOFD.PORT ?? 9090);
@@ -142,6 +144,14 @@ export const boot = async (options: { killProcess: boolean }) => {
142
144
 
143
145
  resumeEmails().catch(console.error);
144
146
 
147
+ if (STAMHOOFD.environment !== 'development' && STAMHOOFD.environment !== 'test') {
148
+ CpuService.startMonitoring();
149
+ }
150
+ else if (STAMHOOFD.environment === 'development') {
151
+ SQLLogger.slowQueryThresholdMs = 200;
152
+ SQLLogger.explainAllAndLogInefficient = true;
153
+ }
154
+
145
155
  if (routerServer.server) {
146
156
  // Default timeout is a bit too short
147
157
  routerServer.server.timeout = 61000;
@@ -235,7 +245,7 @@ export const boot = async (options: { killProcess: boolean }) => {
235
245
  }
236
246
 
237
247
  // Register crons
238
- await import('./crons');
248
+ await import('./crons.js');
239
249
 
240
250
  AuditLogService.listen();
241
251
  PlatformMembershipService.listen();
@@ -47,20 +47,7 @@ async function fetch(query: LimitedFilteredRequest) {
47
47
  async function count(request: LimitedFilteredRequest) {
48
48
  const query = await GetDocumentsEndpoint.buildQuery(request);
49
49
  const uniqueMemberIds = await query.count(SQL.distinct(SQL.column('memberId')));
50
-
51
- if (uniqueMemberIds > 100 || uniqueMemberIds === 0) {
52
- return uniqueMemberIds; // rough estimate
53
- }
54
- // do full count
55
- request.limit = 100;
56
- let count = 0;
57
- let req: LimitedFilteredRequest | null = request;
58
- while (req) {
59
- const result = await fetch(request);
60
- count += result.results.length;
61
- req = result.next ?? null;
62
- }
63
- return count;
50
+ return uniqueMemberIds;
64
51
  };
65
52
 
66
53
  Email.recipientLoaders.set(EmailRecipientFilterType.Documents, { fetch, count });
@@ -1,11 +1,20 @@
1
- import { Email, Member, MemberResponsibilityRecord, Order, Organization, User } from '@stamhoofd/models';
2
- import { compileToSQLFilter } from '@stamhoofd/sql';
3
- import { EmailRecipient, EmailRecipientFilterType, LimitedFilteredRequest, PaginatedResponse, PaymentGeneral, Replacement, StamhoofdFilter } from '@stamhoofd/structures';
1
+ import { BalanceItem, BalanceItemPayment, Email, Member, MemberResponsibilityRecord, Order, Organization, Payment, RecipientLoader, User, Webshop } from '@stamhoofd/models';
2
+ import { compileToSQLFilter, SQL } from '@stamhoofd/sql';
3
+ import { BalanceItemRelationType, BalanceItemType, CountFilteredRequest, EmailRecipient, EmailRecipientFilterType, LimitedFilteredRequest, PaginatedResponse, PaymentGeneral, PaymentMethod, PaymentMethodHelper, Replacement, StamhoofdFilter, Webshop as WebshopStruct } from '@stamhoofd/structures';
4
4
  import { Formatter } from '@stamhoofd/utility';
5
5
  import { GetPaymentsEndpoint } from '../endpoints/organization/dashboard/payments/GetPaymentsEndpoint.js';
6
+ import { createOrderDataHTMLTable, createPaymentDataHTMLTable } from '../helpers/email-html-helpers.js';
6
7
  import { memberResponsibilityRecordFilterCompilers } from '../sql-filters/member-responsibility-records.js';
7
8
 
8
- async function getRecipients(result: PaginatedResponse<PaymentGeneral[], LimitedFilteredRequest>, type: EmailRecipientFilterType.Payment | EmailRecipientFilterType.PaymentOrganization, subFilter: StamhoofdFilter | null) {
9
+ type ReplacementsOptions = {
10
+ shouldAddReplacementsForOrder: boolean;
11
+ shouldAddReplacementsForTransfers: boolean;
12
+ orderMap: Map<string, Order>;
13
+ webshopMap: Map<string, Webshop>;
14
+ organizationMap: Map<string, Organization>;
15
+ };
16
+
17
+ async function getRecipients(result: PaginatedResponse<PaymentGeneral[], LimitedFilteredRequest>, type: EmailRecipientFilterType.Payment | EmailRecipientFilterType.PaymentOrganization, subFilter: StamhoofdFilter | null, beforeFetchAllResult: BeforeFetchAllResult | undefined) {
9
18
  const recipients: EmailRecipient[] = [];
10
19
  const userIds: { userId: string; payment: PaymentGeneral }[] = [];
11
20
  const organizationIds: { organizationId: string; payment: PaymentGeneral }[] = [];
@@ -71,20 +80,58 @@ async function getRecipients(result: PaginatedResponse<PaymentGeneral[], Limited
71
80
  }
72
81
  }
73
82
 
83
+ // get all orders linked to the payments
84
+ const allOrderIdsSet = new Set<string>();
85
+ const allWebshopIdsSet = new Set<string>();
86
+ const organizationIdsForOrdersSet = new Set<string>();
87
+
88
+ for (const payment of result.results) {
89
+ payment.webshopIds.forEach(id => allWebshopIdsSet.add(id));
90
+
91
+ for (const balanceItemPayment of payment.balanceItemPayments) {
92
+ const balanceItem = balanceItemPayment.balanceItem;
93
+ if (balanceItem.orderId) {
94
+ allOrderIdsSet.add(balanceItem.orderId);
95
+
96
+ // only important if balance item has order
97
+ organizationIdsForOrdersSet.add(balanceItem.organizationId);
98
+ }
99
+ }
100
+ }
101
+
102
+ // get all orders (for replacements later)
103
+ const orders = await Order.getByIDs(...allOrderIdsSet);
104
+ const orderMap = new Map<string, Order>(orders.map(o => [o.id, o] as [string, Order]));
105
+
106
+ // get all webshops (for replacements later)
107
+ const webshops = await Webshop.getByIDs(...allWebshopIdsSet);
108
+ const webshopMap = new Map<string, Webshop>(webshops.map(w => [w.id, w] as [string, Webshop]));
109
+
110
+ // get all organizations (for replacements later)
111
+ const organizations = await Organization.getByIDs(...organizationIdsForOrdersSet);
112
+ const organizationMap = new Map<string, Organization>(organizations.map(o => [o.id, o] as [string, Organization]));
113
+
114
+ const replacementOptions: ReplacementsOptions = {
115
+ shouldAddReplacementsForOrder: beforeFetchAllResult ? !beforeFetchAllResult.doesIncludePaymentWithoutOrders : false,
116
+ shouldAddReplacementsForTransfers: beforeFetchAllResult ? beforeFetchAllResult.areAllPaymentsTransfers : false,
117
+ orderMap,
118
+ webshopMap,
119
+ organizationMap,
120
+ };
121
+
74
122
  if (type === EmailRecipientFilterType.Payment) {
75
- recipients.push(...await getUserRecipients(userIds));
76
- recipients.push(...await getMemberRecipients(memberIds));
77
- recipients.push(...await getUserRecipients(userIds));
78
- recipients.push(...await getOrderRecipients(orderIds));
123
+ recipients.push(...await getUserRecipients(userIds, replacementOptions));
124
+ recipients.push(...await getMemberRecipients(memberIds, replacementOptions));
125
+ recipients.push(...await getOrderRecipients(orderIds, replacementOptions));
79
126
  }
80
127
  else {
81
- recipients.push(...await getOrganizationRecipients(organizationIds, subFilter));
128
+ recipients.push(...await getOrganizationRecipients(organizationIds, replacementOptions, subFilter));
82
129
  }
83
130
 
84
131
  return recipients;
85
132
  }
86
133
 
87
- async function getUserRecipients(ids: { userId: string; payment: PaymentGeneral }[]): Promise<EmailRecipient[]> {
134
+ async function getUserRecipients(ids: { userId: string; payment: PaymentGeneral }[], replacementOptions: ReplacementsOptions): Promise<EmailRecipient[]> {
88
135
  if (ids.length === 0) {
89
136
  return [];
90
137
  }
@@ -104,7 +151,7 @@ async function getUserRecipients(ids: { userId: string; payment: PaymentGeneral
104
151
  firstName: user.firstName,
105
152
  lastName: user.lastName,
106
153
  email: user.email,
107
- replacements: getEmailReplacementsForPayment(payment),
154
+ replacements: getEmailReplacementsForPayment(payment, replacementOptions),
108
155
  }));
109
156
  }
110
157
  }
@@ -152,15 +199,19 @@ async function getMembersForOrganizations(organizationIds: string[], filter: Sta
152
199
  return result;
153
200
  }
154
201
 
155
- async function getOrganizationRecipients(ids: { organizationId: string; payment: PaymentGeneral }[], subFilter: StamhoofdFilter | null): Promise<EmailRecipient[]> {
202
+ async function getOrganizationRecipients(ids: { organizationId: string; payment: PaymentGeneral }[], replacementOptions: ReplacementsOptions, subFilter: StamhoofdFilter | null): Promise<EmailRecipient[]> {
156
203
  if (ids.length === 0 || subFilter === null) {
157
204
  return [];
158
205
  }
159
206
 
160
207
  const allOrganizationIds = Formatter.uniqueArray(ids.map(i => i.organizationId));
161
- const organizations = await Organization.getByIDs(...allOrganizationIds);
162
- if (!organizations.length) {
163
- return [];
208
+ const organizationMap = replacementOptions.organizationMap;
209
+
210
+ // fetch organizations that are not in map yet
211
+ const fetchedOrganizations = await Organization.getByIDs(...allOrganizationIds.filter(id => !organizationMap.has(id)));
212
+
213
+ for (const organization of fetchedOrganizations) {
214
+ organizationMap.set(organization.id, organization);
164
215
  }
165
216
 
166
217
  const membersForOrganizations = await getMembersForOrganizations(allOrganizationIds, subFilter);
@@ -168,7 +219,7 @@ async function getOrganizationRecipients(ids: { organizationId: string; payment:
168
219
  const results: EmailRecipient[] = [];
169
220
 
170
221
  for (const { organizationId, payment } of ids) {
171
- const organization = organizations.find(o => o.id === organizationId);
222
+ const organization = organizationMap.get(organizationId);
172
223
 
173
224
  if (organization) {
174
225
  const members = membersForOrganizations.get(organizationId);
@@ -176,7 +227,7 @@ async function getOrganizationRecipients(ids: { organizationId: string; payment:
176
227
  continue;
177
228
  }
178
229
 
179
- const replacements = getEmailReplacementsForPayment(payment);
230
+ const replacements = getEmailReplacementsForPayment(payment, replacementOptions);
180
231
 
181
232
  for (const member of members) {
182
233
  for (const email of member.details.getMemberEmails()) {
@@ -197,7 +248,7 @@ async function getOrganizationRecipients(ids: { organizationId: string; payment:
197
248
  return results;
198
249
  }
199
250
 
200
- async function getMemberRecipients(ids: { memberId: string; payment: PaymentGeneral }[]): Promise<EmailRecipient[]> {
251
+ async function getMemberRecipients(ids: { memberId: string; payment: PaymentGeneral }[], replacementOptions: ReplacementsOptions): Promise<EmailRecipient[]> {
201
252
  if (ids.length === 0) {
202
253
  return [];
203
254
  }
@@ -225,7 +276,7 @@ async function getMemberRecipients(ids: { memberId: string; payment: PaymentGene
225
276
  firstName: user.firstName,
226
277
  lastName: user.lastName,
227
278
  email: user.email,
228
- replacements: getEmailReplacementsForPayment(payment),
279
+ replacements: getEmailReplacementsForPayment(payment, replacementOptions),
229
280
  });
230
281
  results.push(recipient);
231
282
  }
@@ -235,18 +286,16 @@ async function getMemberRecipients(ids: { memberId: string; payment: PaymentGene
235
286
  return results;
236
287
  }
237
288
 
238
- async function getOrderRecipients(ids: { orderId: string; payment: PaymentGeneral }[]): Promise<EmailRecipient[]> {
289
+ async function getOrderRecipients(ids: { orderId: string; payment: PaymentGeneral }[], replacementOptions: ReplacementsOptions): Promise<EmailRecipient[]> {
239
290
  if (ids.length === 0) {
240
291
  return [];
241
292
  }
242
293
 
243
- const allOrderIds = Formatter.uniqueArray(ids.map(i => i.orderId));
244
- const orders = await Order.getByIDs(...allOrderIds);
245
-
294
+ const orderMap = replacementOptions.orderMap;
246
295
  const results: EmailRecipient[] = [];
247
296
 
248
297
  for (const { orderId, payment } of ids) {
249
- const order = orders.find(o => o.id === orderId);
298
+ const order = orderMap.get(orderId);
250
299
 
251
300
  if (order) {
252
301
  const { firstName, lastName, email } = order.data.customer;
@@ -257,7 +306,7 @@ async function getOrderRecipients(ids: { orderId: string; payment: PaymentGenera
257
306
  firstName,
258
307
  lastName,
259
308
  email,
260
- replacements: getEmailReplacementsForPayment(payment),
309
+ replacements: getEmailReplacementsForPayment(payment, replacementOptions),
261
310
  }));
262
311
  }
263
312
  }
@@ -265,24 +314,223 @@ async function getOrderRecipients(ids: { orderId: string; payment: PaymentGenera
265
314
  return results;
266
315
  }
267
316
 
268
- function getEmailReplacementsForPayment(payment: PaymentGeneral): Replacement[] {
269
- // todo
270
- return [];
317
+ function getEmailReplacementsForPayment(payment: PaymentGeneral, options: ReplacementsOptions): Replacement[] {
318
+ const { orderMap, webshopMap, organizationMap, shouldAddReplacementsForOrder, shouldAddReplacementsForTransfers } = options;
319
+ const orderIds = new Set<string>();
320
+
321
+ for (const balanceItemPayment of payment.balanceItemPayments) {
322
+ const orderId = balanceItemPayment.balanceItem.orderId;
323
+ if (orderId) {
324
+ orderIds.add(orderId);
325
+ }
326
+ }
327
+
328
+ // will be set if only 1 order is linked
329
+ let singleOrder: Order | null = null;
330
+
331
+ if (orderIds.size === 1) {
332
+ const singleOrderId = [...orderIds][0];
333
+ if (singleOrderId) {
334
+ const order = orderMap.get(singleOrderId);
335
+
336
+ if (order) {
337
+ singleOrder = order;
338
+ }
339
+ }
340
+ }
341
+
342
+ let orderUrlReplacement: Replacement | null = null;
343
+
344
+ // add replacement for order url if only 1 order is linked
345
+ if (singleOrder && shouldAddReplacementsForOrder) {
346
+ const webshop = webshopMap.get(singleOrder.webshopId);
347
+ const organization = organizationMap.get(singleOrder.organizationId);
348
+
349
+ if (webshop && organization) {
350
+ const webshopStruct = WebshopStruct.create(webshop);
351
+
352
+ orderUrlReplacement = Replacement.create({
353
+ token: 'orderUrl',
354
+ value: 'https://' + webshopStruct.getUrl(organization) + '/order/' + (singleOrder.id),
355
+ });
356
+ }
357
+ }
358
+
359
+ const createPaymentDataHtml = () => {
360
+ if (singleOrder) {
361
+ const webshop = webshopMap.get(singleOrder.webshopId);
362
+ if (webshop) {
363
+ return createOrderDataHTMLTable(singleOrder, webshop);
364
+ }
365
+ }
366
+
367
+ return createPaymentDataHTMLTable(payment);
368
+ };
369
+
370
+ const paymentDataHtml = createPaymentDataHtml();
371
+ const paymentDataReplacement = paymentDataHtml
372
+ ? Replacement.create({
373
+ token: 'paymentData',
374
+ value: '',
375
+ html: paymentDataHtml,
376
+ })
377
+ : null;
378
+
379
+ return ([
380
+ Replacement.create({
381
+ token: 'paymentPrice',
382
+ value: Formatter.price(payment.price),
383
+ }),
384
+ Replacement.create({
385
+ token: 'paymentMethod',
386
+ value: PaymentMethodHelper.getName(payment.method ?? PaymentMethod.Unknown),
387
+ }),
388
+ ...(shouldAddReplacementsForTransfers
389
+ ? [
390
+ Replacement.create({
391
+ token: 'transferDescription',
392
+ value: (payment.transferDescription ?? ''),
393
+ }),
394
+ Replacement.create({
395
+ token: 'transferBankAccount',
396
+ value: payment.transferSettings?.iban ?? '',
397
+ }),
398
+ Replacement.create({
399
+ token: 'transferBankCreditor',
400
+ // todo?
401
+ value: payment.transferSettings?.creditor ?? (payment.organizationId ? organizationMap.get(payment.organizationId)?.name : ''),
402
+ }),
403
+ ]
404
+ : []),
405
+
406
+ Replacement.create({
407
+ token: 'balanceItemPaymentsTable',
408
+ value: '',
409
+ // todo: unbox orders?
410
+ html: payment.getBalanceItemPaymentsHtmlTable(),
411
+ }),
412
+ Replacement.create({
413
+ token: 'paymentTable',
414
+ value: '',
415
+ html: payment.getHTMLTable(),
416
+ }),
417
+ Replacement.create({
418
+ token: 'overviewContext',
419
+ value: getPaymentContext(payment, options),
420
+ }),
421
+ orderUrlReplacement,
422
+ paymentDataReplacement,
423
+ ]).filter(replacementOrNull => replacementOrNull !== null);
424
+ }
425
+
426
+ function getPaymentContext(payment: PaymentGeneral, { orderMap, webshopMap }: ReplacementsOptions) {
427
+ const overviewContext = new Set<string>();
428
+ const registrationMemberNames = new Set<string>();
429
+
430
+ // only add to context if type is order or registration
431
+ for (const balanceItemPayment of payment.balanceItemPayments) {
432
+ const balanceItem = balanceItemPayment.balanceItem;
433
+ const type = balanceItem.type;
434
+
435
+ switch (type) {
436
+ case BalanceItemType.Order: {
437
+ if (balanceItem.orderId) {
438
+ const order = orderMap.get(balanceItem.orderId);
439
+
440
+ if (order) {
441
+ const webshop = webshopMap.get(order.webshopId);
442
+ if (webshop) {
443
+ overviewContext.add($t('{webshop} (bestelling {orderNumber})', {
444
+ webshop: webshop.meta.name,
445
+ orderNumber: order.number ?? '',
446
+ }));
447
+ }
448
+ else {
449
+ overviewContext.add($t('Bestelling {orderNumber}', {
450
+ orderNumber: order.number ?? '',
451
+ }));
452
+ }
453
+ }
454
+ }
455
+ break;
456
+ }
457
+ case BalanceItemType.Registration: {
458
+ const memberName = balanceItem.relations.get(BalanceItemRelationType.Member)?.name.toString();
459
+ if (memberName) {
460
+ registrationMemberNames.add(memberName);
461
+ }
462
+ else {
463
+ overviewContext.add(balanceItem.itemTitle);
464
+ }
465
+
466
+ break;
467
+ }
468
+ default: {
469
+ break;
470
+ }
471
+ }
472
+ }
473
+
474
+ if (registrationMemberNames.size > 0) {
475
+ const memberNames = Formatter.joinLast([...registrationMemberNames], ', ', ' ' + $t(`6a156458-b396-4d0f-b562-adb3e38fc51b`) + ' ');
476
+ overviewContext.add($t(`01d5fd7e-2960-4eb4-ab3a-2ac6dcb2e39c`) + ' ' + memberNames);
477
+ }
478
+
479
+ if (overviewContext.size === 0) {
480
+ // add item title if no balance items with type order or registration
481
+ if (payment.balanceItemPayments.length === 1) {
482
+ const balanceItem = payment.balanceItemPayments[0].balanceItem;
483
+ return balanceItem.itemTitle;
484
+ }
485
+
486
+ if (payment.balanceItemPayments.length > 1) {
487
+ // return title if all balance items have the same title
488
+ const firstTitle = payment.balanceItemPayments[0].balanceItem.itemTitle;
489
+ const haveAllSameTitle = payment.balanceItemPayments.every(p => p.balanceItem.itemTitle === firstTitle);
490
+
491
+ if (haveAllSameTitle) {
492
+ return `${firstTitle} (${payment.balanceItemPayments.length}x)`;
493
+ }
494
+
495
+ // else return default text for multiple items
496
+ return $t('Betaling voor {count} items', { count: payment.balanceItemPayments.length });
497
+ }
498
+
499
+ // else return default text for single item
500
+ return $t('Betaling voor 1 item');
501
+ }
502
+
503
+ // join texts for balance items with type order or registration
504
+ return [...overviewContext].join(', ');
271
505
  }
272
506
 
273
- async function fetchPaymentRecipients(query: LimitedFilteredRequest) {
507
+ type BeforeFetchAllResult = {
508
+ doesIncludePaymentWithoutOrders: boolean;
509
+ areAllPaymentsTransfers: boolean;
510
+ };
511
+
512
+ async function fetchPaymentRecipients(query: LimitedFilteredRequest, beforeFetchAllResult?: BeforeFetchAllResult) {
274
513
  const result = await GetPaymentsEndpoint.buildData(query);
275
514
 
276
515
  return new PaginatedResponse({
277
- results: await getRecipients(result, EmailRecipientFilterType.Payment, null),
516
+ results: await getRecipients(result, EmailRecipientFilterType.Payment, null, beforeFetchAllResult),
278
517
  next: result.next,
279
518
  });
280
519
  }
281
520
 
282
- Email.recipientLoaders.set(EmailRecipientFilterType.Payment, {
283
- fetch: fetchPaymentRecipients,
521
+ const paymentRecipientLoader: RecipientLoader<BeforeFetchAllResult> = {
522
+ beforeFetchAll: async (query: LimitedFilteredRequest) => {
523
+ const doesIncludePaymentWithoutOrders = await doesQueryIncludePaymentsWithoutOrder(query);
524
+ const areAllPaymentsTransfers = await doesQueryIncludePaymentsOtherThanTransfers(query);
525
+
526
+ return {
527
+ doesIncludePaymentWithoutOrders,
528
+ areAllPaymentsTransfers,
529
+ };
530
+ },
531
+ fetch: async (query: LimitedFilteredRequest, _subfilter, beforeFetchAllResult) => fetchPaymentRecipients(query, beforeFetchAllResult),
284
532
  // For now: only count the number of payments - not the amount of emails
285
- count: async (query: LimitedFilteredRequest) => {
533
+ count: async (query: LimitedFilteredRequest, _subfilter) => {
286
534
  const q = await GetPaymentsEndpoint.buildQuery(query);
287
535
  const base = await q.count();
288
536
 
@@ -295,31 +543,78 @@ Email.recipientLoaders.set(EmailRecipientFilterType.Payment, {
295
543
 
296
544
  return base;
297
545
  },
298
- });
546
+ };
299
547
 
300
- async function fetchPaymentOrganizationRecipients(query: LimitedFilteredRequest, subfilter: StamhoofdFilter | null) {
548
+ Email.recipientLoaders.set(EmailRecipientFilterType.Payment, paymentRecipientLoader);
549
+
550
+ async function doesQueryIncludePaymentsWithoutOrder(filterRequest: LimitedFilteredRequest) {
551
+ // create count request (without limit and page filter)
552
+ const countRequest = new CountFilteredRequest({
553
+ filter: filterRequest.filter,
554
+ search: filterRequest.search,
555
+ });
556
+
557
+ const baseQuery = await GetPaymentsEndpoint.buildQuery(countRequest);
558
+
559
+ const balanceItemPaymentsJoin = SQL.innerJoin(BalanceItemPayment.table)
560
+ .where(
561
+ SQL.column(BalanceItemPayment.table, 'paymentId'),
562
+ SQL.column(Payment.table, 'id'),
563
+ );
564
+
565
+ const balanceItemJoin = SQL.innerJoin(BalanceItem.table)
566
+ .where(
567
+ SQL.column(BalanceItem.table, 'id'),
568
+ SQL.column(BalanceItemPayment.table, 'balanceItemId'),
569
+ );
570
+
571
+ // check if 1 payment without order
572
+ const results = await baseQuery
573
+ .join(balanceItemPaymentsJoin)
574
+ .join(balanceItemJoin)
575
+ // where no order
576
+ .where(SQL.column(BalanceItem.table, 'orderId'), null)
577
+ .limit(1)
578
+ .count();
579
+
580
+ return results > 0;
581
+ }
582
+
583
+ async function doesQueryIncludePaymentsOtherThanTransfers(filterRequest: LimitedFilteredRequest) {
584
+ // create count request (without limit and page filter)
585
+ const countRequest = new CountFilteredRequest({
586
+ filter: filterRequest.filter,
587
+ search: filterRequest.search,
588
+ });
589
+
590
+ const baseQuery = await GetPaymentsEndpoint.buildQuery(countRequest);
591
+
592
+ // check if 1 payment with other method than transfer
593
+ const results = await baseQuery
594
+ // where method is not transfer
595
+ .whereNot(SQL.column(Payment.table, 'method'), PaymentMethod.Transfer)
596
+ .limit(1)
597
+ .count();
598
+
599
+ return results === 0;
600
+ }
601
+
602
+ async function fetchPaymentOrganizationRecipients(query: LimitedFilteredRequest, subfilter: StamhoofdFilter | null, beforeFetchAllResult?: BeforeFetchAllResult) {
301
603
  const result = await GetPaymentsEndpoint.buildData(query);
302
604
 
303
605
  return new PaginatedResponse({
304
- results: await getRecipients(result, EmailRecipientFilterType.PaymentOrganization, subfilter),
606
+ results: await getRecipients(result, EmailRecipientFilterType.PaymentOrganization, subfilter, beforeFetchAllResult),
305
607
  next: result.next,
306
608
  });
307
609
  }
308
610
 
309
- Email.recipientLoaders.set(EmailRecipientFilterType.PaymentOrganization, {
310
- fetch: fetchPaymentOrganizationRecipients,
611
+ const paymentOrganizationRecipientLoader: RecipientLoader<BeforeFetchAllResult> = {
612
+ fetch: async (query: LimitedFilteredRequest, subfilter: StamhoofdFilter | null, beforeFetchAllResult) => fetchPaymentOrganizationRecipients(query, subfilter, beforeFetchAllResult),
311
613
  // For now: only count the number of payments - not the amount of emails
312
614
  count: async (query: LimitedFilteredRequest, subfilter: StamhoofdFilter | null) => {
313
615
  const q = await GetPaymentsEndpoint.buildQuery(query);
314
- const base = await q.count();
315
-
316
- if (base < 1000) {
317
- // Do full scan
318
- query.limit = 1000;
319
- const result = await fetchPaymentOrganizationRecipients(query, subfilter);
320
- return result.results.length;
321
- }
322
-
323
- return base;
616
+ return await q.count();
324
617
  },
325
- });
618
+ };
619
+
620
+ Email.recipientLoaders.set(EmailRecipientFilterType.PaymentOrganization, paymentOrganizationRecipientLoader);