@stamhoofd/backend 2.137.1 → 2.137.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stamhoofd/backend",
3
- "version": "2.137.1",
3
+ "version": "2.137.3",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "exports": {
@@ -51,29 +51,29 @@
51
51
  "@aws-sdk/client-sesv2": "3.1090.0",
52
52
  "@aws-sdk/client-sqs": "3.1090.0",
53
53
  "@aws-sdk/s3-request-presigner": "3.1090.0",
54
- "@bwip-js/node": "4.11.1",
54
+ "@bwip-js/node": "4.11.2",
55
55
  "@mollie/api-client": "4.6.0",
56
56
  "@simonbackx/simple-database": "1.37.1",
57
57
  "@simonbackx/simple-encoding": "2.26.11",
58
- "@simonbackx/simple-endpoints": "1.21.1",
58
+ "@simonbackx/simple-endpoints": "1.22.0",
59
59
  "@simonbackx/simple-errors": "1.5.0",
60
60
  "@simonbackx/simple-logging": "1.0.1",
61
- "@stamhoofd/backend-env": "2.137.1",
62
- "@stamhoofd/backend-i18n": "2.137.1",
63
- "@stamhoofd/backend-middleware": "2.137.1",
64
- "@stamhoofd/crons": "2.137.1",
65
- "@stamhoofd/email": "2.137.1",
66
- "@stamhoofd/excel-writer": "2.137.1",
67
- "@stamhoofd/logging": "2.137.1",
68
- "@stamhoofd/models": "2.137.1",
69
- "@stamhoofd/object-differ": "2.137.1",
70
- "@stamhoofd/queues": "2.137.1",
71
- "@stamhoofd/sql": "2.137.1",
72
- "@stamhoofd/structures": "2.137.1",
73
- "@stamhoofd/types": "2.137.1",
74
- "@stamhoofd/utility": "2.137.1",
61
+ "@stamhoofd/backend-env": "2.137.3",
62
+ "@stamhoofd/backend-i18n": "2.137.3",
63
+ "@stamhoofd/backend-middleware": "2.137.3",
64
+ "@stamhoofd/crons": "2.137.3",
65
+ "@stamhoofd/email": "2.137.3",
66
+ "@stamhoofd/excel-writer": "2.137.3",
67
+ "@stamhoofd/logging": "2.137.3",
68
+ "@stamhoofd/models": "2.137.3",
69
+ "@stamhoofd/object-differ": "2.137.3",
70
+ "@stamhoofd/queues": "2.137.3",
71
+ "@stamhoofd/sql": "2.137.3",
72
+ "@stamhoofd/structures": "2.137.3",
73
+ "@stamhoofd/types": "2.137.3",
74
+ "@stamhoofd/utility": "2.137.3",
75
75
  "archiver": "7.0.1",
76
- "axios": "1.18.0",
76
+ "axios": "1.18.1",
77
77
  "base-x": "3.0.11",
78
78
  "chalk": "5.6.2",
79
79
  "cookie": "0.7.2",
@@ -82,31 +82,31 @@
82
82
  "jose": "5.10.0",
83
83
  "jsonwebtoken": "9.0.3",
84
84
  "jsvat-next": "3.0.4",
85
- "libphonenumber-js": "1.13.6",
85
+ "libphonenumber-js": "1.13.9",
86
86
  "luxon": "3.7.2",
87
- "mailparser": "3.9.9",
87
+ "mailparser": "3.9.14",
88
88
  "mockdate": "3.0.5",
89
89
  "mysql2": "3.23.1",
90
90
  "node-rsa": "2.0.0",
91
91
  "openid-client": "5.7.1",
92
92
  "stripe": "16.12.0",
93
- "uuid": "14.0.0"
93
+ "uuid": "14.0.1"
94
94
  },
95
95
  "devDependencies": {
96
- "@stamhoofd/test-utils": "2.137.1",
96
+ "@stamhoofd/test-utils": "2.137.3",
97
97
  "@types/cookie": "0.6.0",
98
- "@types/luxon": "3.7.1",
98
+ "@types/luxon": "3.7.2",
99
99
  "@types/mailparser": "3.4.6",
100
100
  "@types/node": "22.20.1",
101
101
  "@types/uuid": "8.3.4",
102
- "nock": "14.0.15",
103
- "qs": "6.15.2",
102
+ "nock": "14.0.16",
103
+ "qs": "6.15.3",
104
104
  "sinon": "18.0.1",
105
- "vitest": "4.1.8"
105
+ "vitest": "4.1.10"
106
106
  },
107
107
  "license": "UNLICENCED",
108
108
  "publishConfig": {
109
109
  "access": "public"
110
110
  },
111
- "gitHead": "951f73f30cf648612da5e4ff22d9db19f5a8f31e"
111
+ "gitHead": "5f2515f1eab66fa40ddb0c19620d2181ccf3d945"
112
112
  }
package/src/boot.ts CHANGED
@@ -4,6 +4,7 @@ import { CORSMiddleware, LogMiddleware, VersionMiddleware } from '@stamhoofd/bac
4
4
  import { checkReadOnly } from '@stamhoofd/crons';
5
5
  import { Email } from '@stamhoofd/email';
6
6
  import { loadLogger } from '@stamhoofd/logging';
7
+ import { CpuService } from '@stamhoofd/logging/CpuService';
7
8
  import { Version } from '@stamhoofd/structures';
8
9
  import { sleep } from '@stamhoofd/utility';
9
10
 
@@ -20,14 +21,13 @@ import { ContextMiddleware } from './middleware/ContextMiddleware.js';
20
21
  import { AuditLogService } from './services/AuditLogService.js';
21
22
  import { BalanceItemService } from './services/BalanceItemService.js';
22
23
  import { BootChecksService } from './services/BootChecksService.js';
23
- import { CpuService } from './services/CpuService.js';
24
24
  import { DocumentService } from './services/DocumentService.js';
25
25
  import { FileSignService } from './services/FileSignService.js';
26
26
  import { PlatformMembershipService } from './services/PlatformMembershipService.js';
27
27
  import { UitpasService } from './services/uitpas/UitpasService.js';
28
28
  import { UniqueMemberNumberService } from './services/UniqueMemberNumberService.js';
29
29
  import { UniqueUserService } from './services/UniqueUserService.js';
30
- import { QueryableModel } from '@stamhoofd/sql';
30
+ import { QueryableModel, SQLLogger } from '@stamhoofd/sql';
31
31
 
32
32
  process.on('unhandledRejection', (error: Error) => {
33
33
  console.error('unhandledRejection');
@@ -176,6 +176,19 @@ export const boot = async (options: { killProcess: boolean }) => {
176
176
  resumeEmails().catch(console.error);
177
177
 
178
178
  if (STAMHOOFD.environment !== 'development' && STAMHOOFD.environment !== 'test') {
179
+ // Tune SQL slow-query logging based on CPU load: under heavy load timings are unreliable,
180
+ // and when idle we can afford to log all slow queries.
181
+ CpuService.addSampleListener((average5s) => {
182
+ if (average5s > 80) {
183
+ // Danger zone: don't log slow queries any longer because the timings won't be trustworthy.
184
+ SQLLogger.slowQueryThresholdMs = null;
185
+ } else if (average5s < 20) {
186
+ // No load, safe to log all slow queries
187
+ SQLLogger.slowQueryThresholdMs = 300;
188
+ } else {
189
+ SQLLogger.slowQueryThresholdMs = 500;
190
+ }
191
+ });
179
192
  CpuService.startMonitoring();
180
193
  } else if (STAMHOOFD.environment === 'development') {
181
194
  const { loadDebugFunctions } = await import('./debug.js');
@@ -187,16 +187,6 @@ export class GetInvoicesEndpoint extends Endpoint<Params, Query, Body, ResponseB
187
187
  }
188
188
  }
189
189
 
190
- for (const invoice of invoices) {
191
- if (invoice.number && !invoice.pdf && STAMHOOFD.environment === 'development') {
192
- await InvoicePdfService.generatePdf(invoice);
193
- }
194
-
195
- if (invoice.number && STAMHOOFD.environment === 'development') {
196
- await InvoiceXMlService.generateXml(invoice);
197
- }
198
- }
199
-
200
190
  return new PaginatedResponse<InvoiceStruct[], LimitedFilteredRequest>({
201
191
  results: await AuthenticatedStructures.invoices(invoices, true),
202
192
  next,
@@ -57,6 +57,24 @@ export class PatchInvoicesEndpoint extends Endpoint<Params, Query, Body, Respons
57
57
  invoices.push(model);
58
58
  }
59
59
 
60
+ for (const patch of request.body.getPatches()) {
61
+ const model = await Invoice.getByID(patch.id);
62
+ if (!model || model.organizationId !== organization.id) {
63
+ throw Context.auth.notFoundOrNoAccess($t('%ZcE'));
64
+ }
65
+
66
+ if (!model.number) {
67
+ // ignore
68
+ continue;
69
+ }
70
+
71
+ // Create PDF
72
+ if (patch.pdf === null) {
73
+ await InvoiceService.retryInvoiceGenerationAndSending(model);
74
+ }
75
+ invoices.push(model);
76
+ }
77
+
60
78
  for (const id of request.body.getDeletes()) {
61
79
  const model = await Invoice.getByID(id);
62
80
  if (!model || model.organizationId !== organization.id) {
@@ -2,7 +2,7 @@ import { ArrayDecoder, AutoEncoder, field, StringDecoder } from '@simonbackx/sim
2
2
  import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
3
3
  import { Endpoint, Response } from '@simonbackx/simple-endpoints';
4
4
  import { SimpleError } from '@simonbackx/simple-errors';
5
- import { CpuService } from '../../services/CpuService.js';
5
+ import { CpuService } from '@stamhoofd/logging/CpuService';
6
6
  import { StartupHealthService } from '../../services/StartupHealthService.js';
7
7
 
8
8
  type Params = Record<string, never>;
@@ -258,26 +258,62 @@ export class InvoicePdfService {
258
258
  const timeout = setTimeout(() => controller.abort(), 30_000);
259
259
 
260
260
  try {
261
- // Issue with system trusted CA in development
262
- const result = await fetch((STAMHOOFD.environment === 'development' ? 'http://' : 'https://') + STAMHOOFD.domains.rendererApi + '/v' + Version + '/html-to-pdf', {
263
- method: 'POST',
264
- body: form,
265
- signal: controller.signal,
266
- });
267
- if (result.status === 200) {
268
- // todo
269
- const buffer = Buffer.from(await result.arrayBuffer());
270
- const file = await this.uploadPdf(invoice, buffer);
261
+ if (STAMHOOFD.environment === 'test') {
262
+ const file = new File({
263
+ id: uuidv4(),
264
+ server: 'https://' + STAMHOOFD.SPACES_BUCKET + '.' + STAMHOOFD.SPACES_ENDPOINT,
265
+ path: 'test.pdf',
266
+ size: 0,
267
+ name: (invoice.number ?? invoice.id),
268
+ isPrivate: false,
269
+ contentType: 'application/pdf',
270
+ });
271
271
  invoice.pdf = file;
272
272
  await invoice.save();
273
273
  } else {
274
+ // Issue with system trusted CA in development
275
+ const result = await fetch((STAMHOOFD.environment === 'development' ? 'http://' : 'https://') + STAMHOOFD.domains.rendererApi + '/v' + Version + '/html-to-pdf', {
276
+ method: 'POST',
277
+ body: form,
278
+ signal: controller.signal,
279
+ });
280
+ if (result.status === 200) {
274
281
  // todo
282
+ const buffer = Buffer.from(await result.arrayBuffer());
283
+ const file = await this.uploadPdf(invoice, buffer);
284
+ invoice.pdf = file;
285
+ await invoice.save();
286
+ } else {
287
+ let txt = '';
288
+ try {
289
+ txt = await result.text();
290
+ } catch (e) {
291
+ txt = '<unreadable stream>';
292
+ console.error('Could not read response', e);
293
+ }
294
+ console.error('Failed to generate invoice PDF. Non 200 statuscode: ', result.status, txt);
295
+ throw new SimpleError({
296
+ code: 'pdf_creation_timeout',
297
+ message: 'Failed to generate invoice PDF',
298
+ human: $t('Er ging iets mis bij het aanmaken van de factuur'),
299
+ });
300
+ }
275
301
  }
276
302
  } catch (err) {
303
+ console.error('Failed to generate invoice PDF', err);
304
+
277
305
  if (err instanceof DOMException && err.name === 'AbortError') {
278
- throw new Error('Request timed out after 30s');
306
+ throw new SimpleError({
307
+ code: 'pdf_creation_timeout',
308
+ message: 'Failed to generate invoice PDF: timeout',
309
+ human: $t('Er ging iets mis bij het aanmaken van de factuur'),
310
+ });
279
311
  }
280
- console.error(err);
312
+ throw new SimpleError({
313
+ code: 'pdf_creation_failed',
314
+ message: 'Failed to generate invoice PDF',
315
+ human: $t('Er ging iets mis bij het aanmaken van de factuur'),
316
+ });
281
317
  } finally {
282
318
  clearTimeout(timeout);
283
319
  }
@@ -302,6 +302,28 @@ export class InvoiceService {
302
302
  return model;
303
303
  }
304
304
 
305
+ static async retryInvoiceGenerationAndSending(model: Invoice) {
306
+ const hadPdf = !!model.pdf;
307
+ if (!model.pdf) {
308
+ // Create PDF
309
+ await InvoicePdfService.generatePdf(model);
310
+ }
311
+
312
+ if (!model.xml) {
313
+ await InvoiceXMlService.generateXml(model);
314
+ }
315
+
316
+ const organization = await Organization.getByID(model.organizationId);
317
+ if (!organization) {
318
+ console.error('Unexpected missing organization for ' + model.id);
319
+ return;
320
+ }
321
+
322
+ if (!await this.forwardInvoice(model, organization) && !model.didSendPeppol && !hadPdf) {
323
+ await this.sendCustomerEmail(model, organization);
324
+ }
325
+ }
326
+
305
327
  /**
306
328
  * Permanently delete an invoice.
307
329
  *
@@ -250,11 +250,11 @@ export class MollieService {
250
250
 
251
251
  details: PaymentMandateDetails.create({
252
252
  name: ('consumerName' in details ? details.consumerName : details.cardHolder) ?? undefined,
253
- cardNumber: 'cardNumber' in details ? details.cardNumber : null,
254
- iban: 'consumerAccount' in details ? Formatter.iban(details.consumerAccount) : null,
255
- bic: ('consumerBic' in details ? details.consumerBic : undefined),
256
- expiryDate: ('cardExpiryDate' in details ? DateTime.fromISO(details.cardExpiryDate, { zone: Formatter.timezone }).toJSDate() : null), // todo: parse date correctly in Brussels timezone!
257
- brand: ('cardLabel' in details ? details.cardLabel : null),
253
+ cardNumber: 'cardNumber' in details && details.cardNumber ? details.cardNumber : null,
254
+ iban: 'consumerAccount' in details && details.consumerAccount ? Formatter.iban(details.consumerAccount) : null,
255
+ bic: ('consumerBic' in details && details.consumerBic ? details.consumerBic : undefined),
256
+ expiryDate: ('cardExpiryDate' in details && details.cardExpiryDate ? DateTime.fromISO(details.cardExpiryDate, { zone: Formatter.timezone }).toJSDate() : null), // todo: parse date correctly in Brussels timezone!
257
+ brand: ('cardLabel' in details && details.cardLabel ? details.cardLabel : null),
258
258
  }),
259
259
  });
260
260
  }
@@ -287,7 +287,6 @@ export class SSOService {
287
287
  httpOnly: true,
288
288
  secure: STAMHOOFD.environment !== 'development',
289
289
  expires: data.expires,
290
- sameSite: 'lax',
291
290
  });
292
291
  }
293
292
 
@@ -1,120 +0,0 @@
1
- import { SQLLogger } from '@stamhoofd/sql';
2
-
3
- class StaticCpuService {
4
- samples: number[];
5
- private maxSamples: number;
6
- private interval?: NodeJS.Timeout;
7
- // Current index = the last saved
8
- private currentIndex = 0;
9
-
10
- constructor() {
11
- this.maxSamples = 5 * 60; // 5 minutes of data
12
- this.samples = new Array(this.maxSamples).fill(0);
13
- }
14
-
15
- /**
16
- * Get a live sample of CPU usage
17
- * @param samplingInterval
18
- * @returns
19
- */
20
- async takeSample(samplingInterval = 500): Promise<number> {
21
- const startUsage = process.cpuUsage();
22
- const startTime = process.hrtime.bigint();
23
-
24
- return new Promise((resolve) => {
25
- setTimeout(() => {
26
- const elapsedUsage = process.cpuUsage(startUsage);
27
- const elapsedTime = process.hrtime.bigint() - startTime;
28
-
29
- // Convert to microseconds
30
- const elapsedTimeMs = Number(elapsedTime) / 1000;
31
-
32
- // CPU time in microseconds
33
- const totalCPUTime = elapsedUsage.user + elapsedUsage.system;
34
-
35
- // Calculate percentage
36
- const cpuPercent = (totalCPUTime / elapsedTimeMs) * 100;
37
-
38
- resolve(cpuPercent);
39
- }, samplingInterval);
40
- });
41
- }
42
-
43
- getCpuUsage(): number | undefined {
44
- if (this.currentIndex === 0) {
45
- return this.samples[this.samples.length - 1];
46
- }
47
- return this.samples[this.currentIndex - 1];
48
- }
49
-
50
- getAverage(size = this.maxSamples): number {
51
- if (size > this.maxSamples) {
52
- size = this.maxSamples;
53
- }
54
- if (size <= 0) {
55
- return NaN;
56
- }
57
- if (size === this.maxSamples) {
58
- return this.samples.reduce((a, b) => a + b, 0) / size;
59
- } else {
60
- // To read before current index
61
- const toReadBeforeIndex = Math.min(size, this.currentIndex);
62
- const toReadFromEnd = size - toReadBeforeIndex;
63
-
64
- // Sum performantly
65
- let sum = 0;
66
- for (let i = 0; i < toReadBeforeIndex; i++) {
67
- sum += this.samples[this.currentIndex - 1 - i];
68
- }
69
- for (let i = 0; i < toReadFromEnd; i++) {
70
- sum += this.samples[this.samples.length - 1 - i];
71
- }
72
-
73
- return sum / size;
74
- }
75
- }
76
-
77
- private async saveSample() {
78
- const sample = await this.takeSample(1000);
79
- this.samples[this.currentIndex] = sample;
80
- this.currentIndex = (this.currentIndex + 1) % this.maxSamples;
81
- const five = this.getAverage(5);
82
-
83
- if (this.currentIndex % 5 === 0 || five > 80) {
84
- const min = this.getAverage(60);
85
- console.log(`[CPU] 5s: ${five.toFixed(2)}%\n[CPU] 1 min: ${min.toFixed(2)}%\n[CPU] 5 min: ${this.getAverage(60 * 5).toFixed(2)}%`);
86
- }
87
-
88
- if (five > 80) {
89
- // Danger zone, in this case we don't want to log all slow queries any longer because the information won't be trustworthy.
90
- SQLLogger.slowQueryThresholdMs = null;
91
- } else {
92
- if (five < 20) {
93
- // No load, safe to log all slow queries
94
- SQLLogger.slowQueryThresholdMs = 300;
95
- } else {
96
- SQLLogger.slowQueryThresholdMs = 500;
97
- }
98
- }
99
- }
100
-
101
- startMonitoring() {
102
- if (this.interval) {
103
- return;
104
- }
105
- this.interval = setInterval(() => {
106
- this.saveSample().catch((error) => {
107
- console.error('Failed to take CPU sample:', error);
108
- });
109
- }, 1000); // Sample every second
110
- }
111
-
112
- stopMonitoring() {
113
- if (this.interval) {
114
- clearInterval(this.interval);
115
- this.interval = undefined;
116
- }
117
- }
118
- }
119
-
120
- export const CpuService = new StaticCpuService();