@stamhoofd/backend 2.85.3 → 2.86.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/.nvmrc CHANGED
@@ -1 +1 @@
1
- 20.12
1
+ 22
package/index.ts CHANGED
@@ -1,237 +1,15 @@
1
1
  import backendEnv from '@stamhoofd/backend-env';
2
- backendEnv.load({ service: 'api' });
3
2
 
4
- import { Column, Database, Migration } from '@simonbackx/simple-database';
5
- import { CORSPreflightEndpoint, Router, RouterServer } from '@simonbackx/simple-endpoints';
6
- import { CORSMiddleware, LogMiddleware, VersionMiddleware } from '@stamhoofd/backend-middleware';
7
- import { Email } from '@stamhoofd/email';
8
- import { loadLogger } from '@stamhoofd/logging';
9
- import { Version } from '@stamhoofd/structures';
10
- import { sleep } from '@stamhoofd/utility';
11
-
12
- import { startCrons, stopCrons, waitForCrons } from '@stamhoofd/crons';
13
- import { Platform } from '@stamhoofd/models';
14
- import { resumeEmails } from './src/helpers/EmailResumer';
15
- import { GlobalHelper } from './src/helpers/GlobalHelper';
16
- import { SetupStepUpdater } from './src/helpers/SetupStepUpdater';
17
- import { ContextMiddleware } from './src/middleware/ContextMiddleware';
18
- import { AuditLogService } from './src/services/AuditLogService';
19
- import { BalanceItemService } from './src/services/BalanceItemService';
20
- import { DocumentService } from './src/services/DocumentService';
21
- import { FileSignService } from './src/services/FileSignService';
22
- import { PlatformMembershipService } from './src/services/PlatformMembershipService';
23
- import { UniqueUserService } from './src/services/UniqueUserService';
24
- import { QueueHandler } from '@stamhoofd/queues';
25
- import { SimpleError } from '@simonbackx/simple-errors';
26
-
27
- process.on('unhandledRejection', (error: Error) => {
28
- console.error('unhandledRejection');
29
- console.error(error.message, error.stack);
3
+ backendEnv.load({ service: 'api' }).catch((error) => {
4
+ console.error('Failed to load environment:', error);
30
5
  process.exit(1);
31
- });
32
-
33
- // Set version of saved structures
34
- Column.setJSONVersion(Version);
35
-
36
- // Set timezone
37
- process.env.TZ = 'UTC';
38
-
39
- // Quick check
40
- if (new Date().getTimezoneOffset() !== 0) {
41
- throw new Error('Process should always run in UTC timezone');
42
- }
43
-
44
- const seeds = async () => {
45
- try {
46
- // Internal
47
- await AuditLogService.disable(async () => {
48
- await Migration.runAll(__dirname + '/src/seeds');
49
- });
50
- }
51
- catch (e) {
52
- console.error('Failed to run seeds:');
53
- console.error(e);
6
+ }).then(async () => {
7
+ if (STAMHOOFD.environment === 'development') {
8
+ const { run } = await import('./src/migrate');
9
+ await run();
54
10
  }
55
- };
56
- const bootTime = process.hrtime();
57
-
58
- const start = async () => {
59
- console.log('Running server at v' + Version);
60
- loadLogger();
61
-
62
- await GlobalHelper.load();
63
- await UniqueUserService.check();
64
-
65
- // Init platform shared struct: otherwise permissions won't work with missing responsibilities
66
- console.log('Loading platform...');
67
- await Platform.loadCaches();
68
-
69
- const router = new Router();
70
-
71
- console.log('Loading endpoints...');
72
-
73
- // Note: we should load endpoints one by once to have a reliable order of url matching
74
- await router.loadAllEndpoints(__dirname + '/src/endpoints/global/*');
75
- await router.loadAllEndpoints(__dirname + '/src/endpoints/admin/*');
76
- await router.loadAllEndpoints(__dirname + '/src/endpoints/auth');
77
- await router.loadAllEndpoints(__dirname + '/src/endpoints/organization/dashboard/*');
78
- await router.loadAllEndpoints(__dirname + '/src/endpoints/organization/registration');
79
- await router.loadAllEndpoints(__dirname + '/src/endpoints/organization/webshops');
80
- await router.loadAllEndpoints(__dirname + '/src/endpoints/organization/shared');
81
- await router.loadAllEndpoints(__dirname + '/src/endpoints/organization/shared/*');
82
-
83
- router.endpoints.push(new CORSPreflightEndpoint());
84
-
85
- console.log('Creating router...');
86
- const routerServer = new RouterServer(router);
87
- routerServer.verbose = false;
88
-
89
- // Log requests and errors
90
- routerServer.addRequestMiddleware(LogMiddleware);
91
- routerServer.addResponseMiddleware(LogMiddleware);
92
-
93
- routerServer.addResponseMiddleware(FileSignService);
94
- routerServer.addRequestMiddleware(FileSignService);
95
-
96
- // Contexts
97
- routerServer.addRequestMiddleware(ContextMiddleware);
98
-
99
- // Add version headers and minimum version
100
- const versionMiddleware = new VersionMiddleware({
101
- latestVersions: {
102
- android: STAMHOOFD.LATEST_ANDROID_VERSION,
103
- ios: STAMHOOFD.LATEST_IOS_VERSION,
104
- web: Version,
105
- },
106
- minimumVersion: 331,
107
- });
108
- routerServer.addRequestMiddleware(versionMiddleware);
109
- routerServer.addResponseMiddleware(versionMiddleware);
110
-
111
- // Add CORS headers
112
- routerServer.addResponseMiddleware(CORSMiddleware);
113
-
114
- console.log('Loading loaders...');
115
-
116
- // Register Excel loaders
117
- await import('./src/excel-loaders');
118
-
119
- // Register Email Recipient loaders
120
- await import('./src/email-recipient-loaders/members');
121
- await import('./src/email-recipient-loaders/registrations');
122
- await import('./src/email-recipient-loaders/orders');
123
- await import('./src/email-recipient-loaders/receivable-balances');
124
- await import('./src/excel-loaders/registrations');
125
-
126
- console.log('Opening port...');
127
- routerServer.listen(STAMHOOFD.PORT ?? 9090);
128
-
129
- const hrend = process.hrtime(bootTime);
130
- console.log('🟢 HTTP server started in ' + Math.ceil(hrend[0] * 1000 + hrend[1] / 1000000) + 'ms');
131
-
132
- resumeEmails().catch(console.error);
133
-
134
- if (routerServer.server) {
135
- // Default timeout is a bit too short
136
- routerServer.server.timeout = 61000;
137
- }
138
-
139
- let shuttingDown = false;
140
- const shutdown = async () => {
141
- if (shuttingDown) {
142
- return;
143
- }
144
- shuttingDown = true;
145
- console.log('Shutting down...');
146
- // Disable keep alive
147
- routerServer.defaultHeaders = Object.assign(routerServer.defaultHeaders, { Connection: 'close' });
148
- if (routerServer.server) {
149
- routerServer.server.headersTimeout = 5000;
150
- routerServer.server.keepAliveTimeout = 1;
151
- }
152
-
153
- stopCrons();
154
-
155
- if (STAMHOOFD.environment === 'development') {
156
- setTimeout(() => {
157
- console.error('Forcing exit after 5 seconds');
158
- process.exit(1);
159
- }, 5000);
160
- }
161
-
162
- try {
163
- await routerServer.close();
164
- console.log('HTTP server stopped');
165
- }
166
- catch (err) {
167
- console.error('Failed to stop HTTP server:');
168
- console.error(err);
169
- }
170
-
171
- await BalanceItemService.flushAll();
172
- await waitForCrons();
173
- QueueHandler.abortAll(
174
- new SimpleError({
175
- code: 'SHUTDOWN',
176
- message: 'Shutting down',
177
- statusCode: 503,
178
- }),
179
- );
180
- await QueueHandler.awaitAll();
181
-
182
- try {
183
- while (Email.currentQueue.length > 0) {
184
- console.log(`${Email.currentQueue.length} emails still in queue. Waiting 500ms...`);
185
- await sleep(500);
186
- }
187
- }
188
- catch (err) {
189
- console.error('Failed to wait for emails to finish:');
190
- console.error(err);
191
- }
192
-
193
- try {
194
- await Database.end();
195
- console.log('MySQL connections closed');
196
- }
197
- catch (err) {
198
- console.error('Failed to close MySQL connection:');
199
- console.error(err);
200
- }
201
-
202
- // Should not be needed, but added for security as sometimes a promise hangs somewhere
203
- process.exit(0);
204
- };
205
-
206
- process.on('SIGTERM', () => {
207
- console.info('SIGTERM signal received.');
208
- shutdown().catch((e) => {
209
- console.error(e);
210
- process.exit(1);
211
- });
212
- });
213
-
214
- process.on('SIGINT', () => {
215
- console.info('SIGINT signal received.');
216
- shutdown().catch((e) => {
217
- console.error(e);
218
- process.exit(1);
219
- });
220
- });
221
-
222
- // Register crons
223
- await import('./src/crons');
224
-
225
- AuditLogService.listen();
226
- PlatformMembershipService.listen();
227
- DocumentService.listen();
228
- SetupStepUpdater.listen();
229
-
230
- startCrons();
231
- seeds().catch(console.error);
232
- };
233
-
234
- start().catch((error) => {
235
- console.error('unhandledRejection', error);
11
+ await import('./src/boot');
12
+ }).catch((error) => {
13
+ console.error('Failed to start the API:', error);
236
14
  process.exit(1);
237
15
  });
package/migrations.ts CHANGED
@@ -1,35 +1,13 @@
1
1
  import backendEnv from '@stamhoofd/backend-env';
2
- backendEnv.load();
3
2
 
4
- import { Column, Migration } from '@simonbackx/simple-database';
5
- import { Version } from '@stamhoofd/structures';
6
- import path from 'path';
7
-
8
- Column.setJSONVersion(Version);
9
- process.env.TZ = 'UTC';
10
-
11
- const emailPath = require.resolve('@stamhoofd/email');
12
- const modelsPath = require.resolve('@stamhoofd/models');
13
-
14
- // Validate UTC timezone
15
- if (new Date().getTimezoneOffset() !== 0) {
16
- throw new Error('Process should always run in UTC timezone');
17
- }
18
-
19
- const start = async () => {
20
- // External migrations
21
- await Migration.runAll(path.dirname(modelsPath) + '/migrations');
22
- await Migration.runAll(path.dirname(emailPath) + '/migrations');
23
-
24
- // Internal
25
- await Migration.runAll(__dirname + '/src/migrations');
26
- };
27
-
28
- start()
29
- .catch((error) => {
30
- console.error('unhandledRejection', error);
31
- process.exit(1);
32
- })
33
- .finally(() => {
34
- process.exit();
35
- });
3
+ backendEnv.load({ service: 'api' }).catch((error) => {
4
+ console.error('Failed to load environment:', error);
5
+ process.exit(1);
6
+ }).then(async () => {
7
+ const { run } = await import('./src/migrate');
8
+ await run();
9
+ process.exit(0);
10
+ }).catch((error) => {
11
+ console.error('Failed to run migrations:', error);
12
+ process.exit(1);
13
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stamhoofd/backend",
3
- "version": "2.85.3",
3
+ "version": "2.86.0",
4
4
  "main": "./dist/index.js",
5
5
  "exports": {
6
6
  ".": {
@@ -9,16 +9,17 @@
9
9
  },
10
10
  "license": "UNLICENCED",
11
11
  "scripts": {
12
- "dev": "echo 'Waiting for shared backend packages...' && wait-on ../../shared/middleware/dist/index.js && echo 'Start building backend API' && concurrently -r 'yarn build --watch --preserveWatchOutput' \"wait-on ./dist/index.js && nodemon --quiet --inspect=5858 --watch dist --watch '../../../shared/*/dist/' --watch '../../shared/*/dist/' --ext .ts,.json,.sql,.js --watch .env.json --delay 1000ms --exec 'node --enable-source-maps ./dist/index.js' --signal SIGTERM\"",
13
- "dev:backend": "yarn dev",
12
+ "dev": "wait-on ../../shared/middleware/dist/index.js && concurrently -r 'yarn -s build --watch --preserveWatchOutput' \"yarn -s dev:watch\"",
13
+ "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",
14
+ "dev:backend": "yarn -s dev",
14
15
  "build": "rm -rf ./dist/src/migrations && rm -rf ./dist/src/seeds && tsc -b",
15
- "build:full": "yarn clear && yarn build",
16
+ "build:full": "yarn -s clear && yarn -s build",
16
17
  "clear": "rm -rf ./dist",
17
- "start": "yarn build && node --enable-source-maps ./dist/index.js",
18
+ "start": "yarn -s build && node --enable-source-maps ./dist/index.js",
18
19
  "test": "jest --runInBand",
19
20
  "test:build": "yarn -s build:full && yarn -s test",
20
- "test:reset": "yarn build:full && jest --runInBand",
21
- "migrations": "yarn build:full && node ./dist/migrations.js",
21
+ "test:reset": "yarn -s build:full && jest --runInBand",
22
+ "migrations": "yarn -s build:full && node ./dist/migrations.js",
22
23
  "lint": "eslint"
23
24
  },
24
25
  "devDependencies": {
@@ -26,7 +27,7 @@
26
27
  "@types/luxon": "3.4.2",
27
28
  "@types/mailparser": "3.4.4",
28
29
  "@types/mysql": "^2.15.20",
29
- "@types/node": "^20.12",
30
+ "@types/node": "^22",
30
31
  "nock": "^13.5.1",
31
32
  "qs": "^6.11.2",
32
33
  "sinon": "^18.0.0"
@@ -39,22 +40,22 @@
39
40
  "@aws-sdk/s3-request-presigner": "3.823.0",
40
41
  "@bwip-js/node": "^4.5.1",
41
42
  "@mollie/api-client": "3.7.0",
42
- "@simonbackx/simple-database": "1.32.0",
43
+ "@simonbackx/simple-database": "1.33.0",
43
44
  "@simonbackx/simple-encoding": "2.22.0",
44
45
  "@simonbackx/simple-endpoints": "1.20.1",
45
46
  "@simonbackx/simple-logging": "^1.0.1",
46
- "@stamhoofd/backend-i18n": "2.85.3",
47
- "@stamhoofd/backend-middleware": "2.85.3",
48
- "@stamhoofd/email": "2.85.3",
49
- "@stamhoofd/models": "2.85.3",
50
- "@stamhoofd/queues": "2.85.3",
51
- "@stamhoofd/sql": "2.85.3",
52
- "@stamhoofd/structures": "2.85.3",
53
- "@stamhoofd/utility": "2.85.3",
47
+ "@stamhoofd/backend-i18n": "2.86.0",
48
+ "@stamhoofd/backend-middleware": "2.86.0",
49
+ "@stamhoofd/email": "2.86.0",
50
+ "@stamhoofd/models": "2.86.0",
51
+ "@stamhoofd/queues": "2.86.0",
52
+ "@stamhoofd/sql": "2.86.0",
53
+ "@stamhoofd/structures": "2.86.0",
54
+ "@stamhoofd/utility": "2.86.0",
54
55
  "archiver": "^7.0.1",
55
- "axios": "1.6.8",
56
+ "axios": "^1.8.2",
56
57
  "cookie": "^0.5.0",
57
- "formidable": "3.5.1",
58
+ "formidable": "3.5.4",
58
59
  "handlebars": "^4.7.7",
59
60
  "jsonwebtoken": "9.0.0",
60
61
  "luxon": "3.4.4",
@@ -63,11 +64,10 @@
63
64
  "mysql2": "^3.14.1",
64
65
  "node-rsa": "1.1.1",
65
66
  "openid-client": "^5.4.0",
66
- "postmark": "^4.0.5",
67
67
  "stripe": "^16.6.0"
68
68
  },
69
69
  "publishConfig": {
70
70
  "access": "public"
71
71
  },
72
- "gitHead": "5a80111e83be35a396c48ba30d0569d9376b953a"
72
+ "gitHead": "e7db435d283a81bee4f7d26c2ccc61ee1df0524c"
73
73
  }
package/src/boot.ts ADDED
@@ -0,0 +1,240 @@
1
+ import { Column, Database, Migration } from '@simonbackx/simple-database';
2
+ import { CORSPreflightEndpoint, Router, RouterServer } from '@simonbackx/simple-endpoints';
3
+ import { CORSMiddleware, LogMiddleware, VersionMiddleware } from '@stamhoofd/backend-middleware';
4
+ import { Email } from '@stamhoofd/email';
5
+ import { loadLogger } from '@stamhoofd/logging';
6
+ import { Version } from '@stamhoofd/structures';
7
+ import { sleep } from '@stamhoofd/utility';
8
+
9
+ import { startCrons, stopCrons, waitForCrons } from '@stamhoofd/crons';
10
+ import { Platform } from '@stamhoofd/models';
11
+ import { resumeEmails } from './helpers/EmailResumer';
12
+ import { GlobalHelper } from './helpers/GlobalHelper';
13
+ import { SetupStepUpdater } from './helpers/SetupStepUpdater';
14
+ import { ContextMiddleware } from './middleware/ContextMiddleware';
15
+ import { AuditLogService } from './services/AuditLogService';
16
+ import { BalanceItemService } from './services/BalanceItemService';
17
+ import { DocumentService } from './services/DocumentService';
18
+ import { FileSignService } from './services/FileSignService';
19
+ import { PlatformMembershipService } from './services/PlatformMembershipService';
20
+ import { UniqueUserService } from './services/UniqueUserService';
21
+ import { QueueHandler } from '@stamhoofd/queues';
22
+ import { SimpleError } from '@simonbackx/simple-errors';
23
+
24
+ process.on('unhandledRejection', (error: Error) => {
25
+ console.error('unhandledRejection');
26
+ console.error(error.message, error.stack);
27
+ process.exit(1);
28
+ });
29
+
30
+ // Set version of saved structures
31
+ Column.setJSONVersion(Version);
32
+
33
+ // Set timezone
34
+ process.env.TZ = 'UTC';
35
+
36
+ // Quick check
37
+ if (new Date().getTimezoneOffset() !== 0) {
38
+ throw new Error('Process should always run in UTC timezone');
39
+ }
40
+
41
+ const seeds = async () => {
42
+ try {
43
+ // Internal
44
+ await AuditLogService.disable(async () => {
45
+ await Migration.runAll(__dirname + '/seeds');
46
+ });
47
+ }
48
+ catch (e) {
49
+ console.error('Failed to run seeds:');
50
+ console.error(e);
51
+ }
52
+ };
53
+ const bootTime = process.hrtime();
54
+
55
+ function productionLog(message: string) {
56
+ if (STAMHOOFD.environment !== 'development') {
57
+ console.log(message);
58
+ }
59
+ }
60
+
61
+ const start = async () => {
62
+ productionLog('Running server at v' + Version);
63
+ loadLogger();
64
+
65
+ await GlobalHelper.load();
66
+ await UniqueUserService.check();
67
+
68
+ // Init platform shared struct: otherwise permissions won't work with missing responsibilities
69
+ productionLog('Loading platform...');
70
+ await Platform.loadCaches();
71
+
72
+ const router = new Router();
73
+
74
+ productionLog('Loading endpoints...');
75
+
76
+ // Note: we should load endpoints one by once to have a reliable order of url matching
77
+ await router.loadAllEndpoints(__dirname + '/endpoints/global/*');
78
+ await router.loadAllEndpoints(__dirname + '/endpoints/admin/*');
79
+ await router.loadAllEndpoints(__dirname + '/endpoints/auth');
80
+ await router.loadAllEndpoints(__dirname + '/endpoints/organization/dashboard/*');
81
+ await router.loadAllEndpoints(__dirname + '/endpoints/organization/registration');
82
+ await router.loadAllEndpoints(__dirname + '/endpoints/organization/webshops');
83
+ await router.loadAllEndpoints(__dirname + '/endpoints/organization/shared');
84
+ await router.loadAllEndpoints(__dirname + '/endpoints/organization/shared/*');
85
+
86
+ router.endpoints.push(new CORSPreflightEndpoint());
87
+
88
+ productionLog('Creating router...');
89
+ const routerServer = new RouterServer(router);
90
+ routerServer.verbose = false;
91
+
92
+ // Log requests and errors
93
+ routerServer.addRequestMiddleware(LogMiddleware);
94
+ routerServer.addResponseMiddleware(LogMiddleware);
95
+
96
+ routerServer.addResponseMiddleware(FileSignService);
97
+ routerServer.addRequestMiddleware(FileSignService);
98
+
99
+ // Contexts
100
+ routerServer.addRequestMiddleware(ContextMiddleware);
101
+
102
+ // Add version headers and minimum version
103
+ const versionMiddleware = new VersionMiddleware({
104
+ latestVersions: {
105
+ android: STAMHOOFD.LATEST_ANDROID_VERSION,
106
+ ios: STAMHOOFD.LATEST_IOS_VERSION,
107
+ web: Version,
108
+ },
109
+ minimumVersion: 331,
110
+ });
111
+ routerServer.addRequestMiddleware(versionMiddleware);
112
+ routerServer.addResponseMiddleware(versionMiddleware);
113
+
114
+ // Add CORS headers
115
+ routerServer.addResponseMiddleware(CORSMiddleware);
116
+
117
+ productionLog('Loading loaders...');
118
+
119
+ // Register Excel loaders
120
+ await import('./excel-loaders');
121
+
122
+ // Register Email Recipient loaders
123
+ await import('./email-recipient-loaders/members');
124
+ await import('./email-recipient-loaders/registrations');
125
+ await import('./email-recipient-loaders/orders');
126
+ await import('./email-recipient-loaders/receivable-balances');
127
+ await import('./excel-loaders/registrations');
128
+
129
+ productionLog('Opening port...');
130
+ routerServer.listen(STAMHOOFD.PORT ?? 9090);
131
+
132
+ const hrend = process.hrtime(bootTime);
133
+ productionLog('🟢 HTTP server started in ' + Math.ceil(hrend[0] * 1000 + hrend[1] / 1000000) + 'ms');
134
+
135
+ resumeEmails().catch(console.error);
136
+
137
+ if (routerServer.server) {
138
+ // Default timeout is a bit too short
139
+ routerServer.server.timeout = 61000;
140
+ }
141
+
142
+ let shuttingDown = false;
143
+ const shutdown = async () => {
144
+ if (shuttingDown) {
145
+ return;
146
+ }
147
+ shuttingDown = true;
148
+ productionLog('Shutting down...');
149
+ // Disable keep alive
150
+ routerServer.defaultHeaders = Object.assign(routerServer.defaultHeaders, { Connection: 'close' });
151
+ if (routerServer.server) {
152
+ routerServer.server.headersTimeout = 5000;
153
+ routerServer.server.keepAliveTimeout = 1;
154
+ }
155
+
156
+ stopCrons();
157
+
158
+ if (STAMHOOFD.environment === 'development') {
159
+ setTimeout(() => {
160
+ console.error('Forcing exit after 5 seconds');
161
+ process.exit(1);
162
+ }, 5000);
163
+ }
164
+
165
+ try {
166
+ await routerServer.close();
167
+ productionLog('HTTP server stopped');
168
+ }
169
+ catch (err) {
170
+ console.error('Failed to stop HTTP server:');
171
+ console.error(err);
172
+ }
173
+
174
+ await BalanceItemService.flushAll();
175
+ await waitForCrons();
176
+ QueueHandler.abortAll(
177
+ new SimpleError({
178
+ code: 'SHUTDOWN',
179
+ message: 'Shutting down',
180
+ statusCode: 503,
181
+ }),
182
+ );
183
+ await QueueHandler.awaitAll();
184
+
185
+ try {
186
+ while (Email.currentQueue.length > 0) {
187
+ console.log(`${Email.currentQueue.length} emails still in queue. Waiting 500ms...`);
188
+ await sleep(500);
189
+ }
190
+ }
191
+ catch (err) {
192
+ console.error('Failed to wait for emails to finish:');
193
+ console.error(err);
194
+ }
195
+
196
+ try {
197
+ await Database.end();
198
+ productionLog('MySQL connections closed');
199
+ }
200
+ catch (err) {
201
+ console.error('Failed to close MySQL connection:');
202
+ console.error(err);
203
+ }
204
+
205
+ // Should not be needed, but added for security as sometimes a promise hangs somewhere
206
+ process.exit(0);
207
+ };
208
+
209
+ process.on('SIGTERM', () => {
210
+ productionLog('SIGTERM signal received.');
211
+ shutdown().catch((e) => {
212
+ console.error(e);
213
+ process.exit(1);
214
+ });
215
+ });
216
+
217
+ process.on('SIGINT', () => {
218
+ productionLog('SIGINT signal received.');
219
+ shutdown().catch((e) => {
220
+ console.error(e);
221
+ process.exit(1);
222
+ });
223
+ });
224
+
225
+ // Register crons
226
+ await import('./crons');
227
+
228
+ AuditLogService.listen();
229
+ PlatformMembershipService.listen();
230
+ DocumentService.listen();
231
+ SetupStepUpdater.listen();
232
+
233
+ startCrons();
234
+ seeds().catch(console.error);
235
+ };
236
+
237
+ start().catch((error) => {
238
+ console.error('unhandledRejection', error);
239
+ process.exit(1);
240
+ });
@@ -131,7 +131,10 @@ describe('clearExcelCacheHelper', () => {
131
131
  dir.isDirectory = () => true;
132
132
  });
133
133
 
134
- fsMock.readdir.mockReturnValue(Promise.resolve([...directories, file1]));
134
+ fsMock.readdir.mockReturnValue(
135
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
136
+ Promise.resolve([...directories, file1]) as any,
137
+ );
135
138
  // #endregion
136
139
 
137
140
  // act
@@ -1,6 +1,5 @@
1
1
  import './amazon-ses.js';
2
2
  import './clearExcelCache.js';
3
3
  import './endFunctionsOfUsersWithoutRegistration.js';
4
- import './postmark.js';
5
4
  import './update-cached-balances.js';
6
5
  import './balance-emails.js';
@@ -17,6 +17,7 @@ import { SetupStepUpdater } from '../../../helpers/SetupStepUpdater';
17
17
  import { PlatformMembershipService } from '../../../services/PlatformMembershipService';
18
18
  import { RegistrationService } from '../../../services/RegistrationService';
19
19
  import { shouldCheckIfMemberIsDuplicateForPatch } from './shouldCheckIfMemberIsDuplicate';
20
+ import { MemberNumberService } from '../../../services/MemberNumberService';
20
21
 
21
22
  type Params = Record<string, never>;
22
23
  type Query = undefined;
@@ -534,6 +535,17 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
534
535
  // Save if okay
535
536
  await membership.save();
536
537
 
538
+ if (member.details.memberNumber === null) {
539
+ try {
540
+ await MemberNumberService.assignMemberNumber(member, membership);
541
+ }
542
+ catch (error) {
543
+ console.error(`Failed to assign member number for id ${member.id}: ${error.message}`);
544
+ // If the assignment of the member number fails the membership is not created but the member is registered
545
+ continue;
546
+ }
547
+ }
548
+
537
549
  updateMembershipMemberIds.add(member.id);
538
550
  }
539
551
 
@@ -356,7 +356,7 @@ export class PatchOrganizationEndpoint extends Endpoint<Params, Query, Body, Res
356
356
  }
357
357
 
358
358
  const maximumStart = 1000 * 60 * 60 * 24 * 31 * 2; // 2 months in advance
359
- if (period.startDate > new Date(Date.now() + maximumStart)) {
359
+ if (period.startDate > new Date(Date.now() + maximumStart) && STAMHOOFD.environment !== 'development') {
360
360
  throw new SimpleError({
361
361
  code: 'invalid_field',
362
362
  message: 'The period you want to set has not started yet',
@@ -77,6 +77,10 @@ export class ConnectMollieEndpoint extends Endpoint<Params, Query, Body, Respons
77
77
  expressData = {};
78
78
  }
79
79
 
80
+ if (type === 'express') {
81
+ expressData.controller = undefined;
82
+ }
83
+
80
84
  // Create a new Stripe account
81
85
  const stripe = StripeHelper.getInstance();
82
86
  const account = await stripe.accounts.create({
package/src/migrate.ts ADDED
@@ -0,0 +1,65 @@
1
+ import chalk from 'chalk';
2
+
3
+ import { Column, DatabaseInstance, Migration } from '@simonbackx/simple-database';
4
+ import { Version } from '@stamhoofd/structures';
5
+ import path from 'path';
6
+
7
+ Column.setJSONVersion(Version);
8
+ process.env.TZ = 'UTC';
9
+
10
+ const emailPath = require.resolve('@stamhoofd/email');
11
+ const modelsPath = require.resolve('@stamhoofd/models');
12
+
13
+ // Validate UTC timezone
14
+ if (new Date().getTimezoneOffset() !== 0) {
15
+ throw new Error('Process should always run in UTC timezone');
16
+ }
17
+
18
+ const start = async () => {
19
+ if (!STAMHOOFD.DB_DATABASE) {
20
+ throw new Error('STAMHOOFD.DB_DATABASE is not set');
21
+ }
22
+
23
+ let killSignalReceived = false;
24
+ const handler = () => {
25
+ // Ignore
26
+ console.error('Ignoring SIGTERM signal during migration');
27
+ killSignalReceived = true;
28
+ };
29
+
30
+ process.on('SIGTERM', handler);
31
+ process.on('SIGINT', handler);
32
+
33
+ // Create database if not exists
34
+ const query = 'CREATE DATABASE IF NOT EXISTS `' + STAMHOOFD.DB_DATABASE + '` DEFAULT CHARACTER SET = `utf8mb4` DEFAULT COLLATE = `utf8mb4_0900_ai_ci`';
35
+
36
+ // Create a non-focused datbase instance to run the query without selecting a database
37
+ const globalDatabase = new DatabaseInstance({
38
+ database: null,
39
+ });
40
+
41
+ await globalDatabase.statement(query);
42
+
43
+ // External migrations
44
+ await Migration.runAll(path.dirname(modelsPath) + '/migrations');
45
+ await Migration.runAll(path.dirname(emailPath) + '/migrations');
46
+
47
+ // Internal
48
+ await Migration.runAll(__dirname + '/src/migrations');
49
+
50
+ if (killSignalReceived) {
51
+ console.error(chalk.red('Killing process due to received signal during migration'));
52
+ process.exit(1);
53
+ }
54
+
55
+ process.off('SIGTERM', handler);
56
+ process.off('SIGINT', handler);
57
+ };
58
+
59
+ export async function run() {
60
+ await start()
61
+ .catch((error) => {
62
+ console.error('unhandledRejection', error);
63
+ process.exit(1);
64
+ });
65
+ }
@@ -0,0 +1,49 @@
1
+ import { Migration } from '@simonbackx/simple-database';
2
+ import { User } from '@stamhoofd/models';
3
+ import { NewUser, PermissionLevel, Permissions, UserPermissions } from '@stamhoofd/structures';
4
+
5
+ export default new Migration(async () => {
6
+ if (STAMHOOFD.environment !== 'development') {
7
+ console.log('Skipped');
8
+ return;
9
+ }
10
+
11
+ // Check if total users is 0
12
+ const totalUsers = await User.select().count();
13
+ if (totalUsers > 0) {
14
+ console.log('Skipped, users already exist');
15
+ return;
16
+ }
17
+
18
+ // Create a development user with platform level admin access
19
+ const user = await User.register(
20
+ null, // No organization
21
+ NewUser.create({
22
+ id: '00000000-0000-4000-8000-000000000000',
23
+ firstName: 'Stamhoofd',
24
+ lastName: 'Development',
25
+ email: 'hallo@stamhoofd.be',
26
+ password: 'stamhoofd',
27
+ }),
28
+ { allowPlatform: true },
29
+ );
30
+
31
+ if (!user) {
32
+ throw new Error('Failed to create development user');
33
+ }
34
+
35
+ user.verified = true;
36
+ user.permissions = UserPermissions.create({
37
+ globalPermissions: Permissions.create({
38
+ level: PermissionLevel.Full,
39
+ }),
40
+ });
41
+ await user.save();
42
+
43
+ console.log('Created a new development user:');
44
+ console.log('E-mail:', user.email);
45
+ console.log('Password: stamhoofd');
46
+
47
+ // Do something here
48
+ return Promise.resolve();
49
+ });
@@ -0,0 +1,22 @@
1
+ import { Migration } from '@simonbackx/simple-database';
2
+ import { Organization } from '@stamhoofd/models';
3
+
4
+ export async function startRecordsConfigurationMigration() {
5
+ for await (const organization of Organization.select().all()) {
6
+ await organization.save();
7
+ }
8
+ }
9
+
10
+ export default new Migration(async () => {
11
+ if (STAMHOOFD.environment === 'test') {
12
+ console.log('skipped in tests');
13
+ return;
14
+ }
15
+
16
+ if (STAMHOOFD.platformName.toLowerCase() !== 'stamhoofd') {
17
+ console.log('skipped for platform (only runs for Stamhoofd): ' + STAMHOOFD.platformName);
18
+ return;
19
+ }
20
+
21
+ await startRecordsConfigurationMigration();
22
+ });
@@ -182,29 +182,14 @@ export const BalanceItemService = {
182
182
 
183
183
  async markPaid(balanceItem: BalanceItem, payment: Payment | null, organization: Organization) {
184
184
  await this.markDue(balanceItem);
185
+ let shouldMarkUpdated = true;
185
186
 
186
- if (balanceItem.paidAt) {
187
- // Already ran side effects
188
- // If we for example deleted a related order or registration - and we still have the balance item, mark it as paid again, we don't want to reactivate the order or registration
189
- await this.markUpdated(balanceItem, payment, organization);
190
- return;
191
- }
192
-
193
- // It is possible this balance item was earlier paid
194
- // and later the regigstration / order has been canceled and it became a negative balance item - which as some point has been reembursed and marked as 'paid'
195
- // in that case, we should be careful not to mark the registration as valid again
196
-
197
- // If registration
198
- if (balanceItem.registrationId) {
199
- if (balanceItem.type === BalanceItemType.Registration) {
200
- await RegistrationService.markValid(balanceItem.registrationId);
201
- }
202
- }
203
-
204
- // If order
187
+ // For orders, we should always call markPaid on the order - it is safe to call this multiple times
188
+ // we need to call it multiple times, in case the order was previously marked unpaid, and then paid again - then we'll need to recreate the tickets
205
189
  if (balanceItem.orderId) {
206
190
  const order = await Order.getByID(balanceItem.orderId);
207
191
  if (order) {
192
+ shouldMarkUpdated = false;
208
193
  await order.markPaid(payment, organization);
209
194
 
210
195
  // Save number in balance description
@@ -219,6 +204,26 @@ export const BalanceItemService = {
219
204
  }
220
205
  }
221
206
 
207
+ if (balanceItem.paidAt) {
208
+ // Already ran side effects
209
+ // If we for example deleted a related order or registration - and we still have the balance item, mark it as paid again, we don't want to reactivate the order or registration
210
+ if (shouldMarkUpdated) {
211
+ await this.markUpdated(balanceItem, payment, organization);
212
+ }
213
+ return;
214
+ }
215
+
216
+ // It is possible this balance item was earlier paid
217
+ // and later the regigstration / order has been canceled and it became a negative balance item - which as some point has been reembursed and marked as 'paid'
218
+ // in that case, we should be careful not to mark the registration as valid again
219
+
220
+ // If registration
221
+ if (balanceItem.registrationId) {
222
+ if (balanceItem.type === BalanceItemType.Registration) {
223
+ await RegistrationService.markValid(balanceItem.registrationId);
224
+ }
225
+ }
226
+
222
227
  balanceItem.paidAt = new Date();
223
228
  await balanceItem.save();
224
229
  },
@@ -280,6 +280,18 @@ export class PlatformMembershipService {
280
280
  // Check if already have the same membership
281
281
  // if that is the case, we'll keep that one and update the price + dates if the organization matches the cheapest/earliest membership
282
282
  let didFind: MemberPlatformMembership | null = null;
283
+
284
+ // First, try to find any undeletable membership - use this as the priority one and delete all others that can be deleted
285
+ // Check if we do have the same membership for a different organization that cannot be deleted (locked)
286
+ // This is to prevent creating duplicate memberships
287
+ for (const m of activeMemberships) {
288
+ if (m.membershipTypeId === cheapestMembership.membership.id && m.locked) {
289
+ didFind = m;
290
+ break;
291
+ }
292
+ }
293
+
294
+ // Then update all memberships from the same organization for the selected registration date range
283
295
  for (const m of activeMemberships) {
284
296
  if (m.membershipTypeId === cheapestMembership.membership.id && m.organizationId === cheapestMembership.registration.organizationId) {
285
297
  if (!m.locked) {
@@ -295,12 +307,14 @@ export class PlatformMembershipService {
295
307
  }
296
308
  await m.save();
297
309
  }
298
- didFind = m;
299
- break;
310
+
311
+ if (!didFind) {
312
+ didFind = m;
313
+ }
300
314
  }
301
315
  }
302
316
 
303
- // Delete all other generated memberships that are not the cheapest one
317
+ // Delete all other generated memberships that are not the chosen one
304
318
  for (const m of activeMemberships) {
305
319
  if (m.id !== didFind?.id) {
306
320
  if (!m.locked && (m.generated || m.membershipTypeId === cheapestMembership.membership.id)) {
@@ -327,6 +341,7 @@ export class PlatformMembershipService {
327
341
  }
328
342
  }
329
343
 
344
+ // We already have a membership, don't create a new one
330
345
  if (didFind) {
331
346
  continue;
332
347
  }
@@ -1,223 +0,0 @@
1
- import { Email, EmailAddress } from '@stamhoofd/email';
2
- import { AuditLog, Organization } from '@stamhoofd/models';
3
- import { DateTime } from 'luxon';
4
-
5
- import { registerCron } from '@stamhoofd/crons';
6
- import { AuditLogReplacement, AuditLogReplacementType, AuditLogSource, AuditLogType } from '@stamhoofd/structures';
7
-
8
- // Importing postmark returns undefined (this is a bug, so we need to use require)
9
- // eslint-disable-next-line @typescript-eslint/no-require-imports
10
- const postmark = require('postmark') as typeof import('postmark');
11
-
12
- let lastPostmarkCheck: Date | null = null;
13
- let lastPostmarkIds: Set<number> = new Set();
14
-
15
- registerCron('checkPostmarkBounces', checkPostmarkBounces);
16
-
17
- async function saveLog({ email, organization, type, subType, id, response, subject, sender }: { id: number; sender: string; email: string; response: string; subject: string;organization: Organization | undefined; type: AuditLogType; subType?: string }) {
18
- const log = new AuditLog();
19
- log.organizationId = organization?.id ?? null;
20
- log.externalId = 'postmark-bounce-' + id.toString();
21
- log.type = type;
22
- log.objectId = email;
23
- log.source = AuditLogSource.System;
24
- log.replacements = new Map([
25
- ['e', AuditLogReplacement.create({
26
- value: email || '',
27
- type: AuditLogReplacementType.EmailAddress,
28
- })],
29
- ['subType', AuditLogReplacement.key(subType || 'unknown')],
30
- ['response', AuditLogReplacement.longText(response)],
31
- ['sender', AuditLogReplacement.create({
32
- value: sender,
33
- type: AuditLogReplacementType.EmailAddress,
34
- })],
35
- ]);
36
-
37
- if (subject) {
38
- log.replacements.set('subject', AuditLogReplacement.string(subject));
39
- }
40
-
41
- // Check if we already logged this bounce
42
- const existing = await AuditLog.select().where('externalId', log.externalId).first(false);
43
- if (existing) {
44
- console.log('Already logged this bounce, skipping');
45
- return;
46
- }
47
-
48
- await log.save();
49
- }
50
-
51
- async function checkPostmarkBounces() {
52
- if (STAMHOOFD.environment !== 'production') {
53
- return;
54
- }
55
-
56
- const token = STAMHOOFD.POSTMARK_SERVER_TOKEN;
57
- if (!token) {
58
- console.log('No postmark token, skipping postmark bounces');
59
- return;
60
- }
61
- const fromDate = (lastPostmarkCheck ?? new Date(new Date().getTime() - 24 * 60 * 60 * 1000 * 2));
62
- const ET = DateTime.fromJSDate(fromDate).setZone('EST').toISO({ includeOffset: false });
63
-
64
- if (!ET) {
65
- console.error('Could not convert date to EST:', fromDate);
66
- return;
67
- }
68
- console.log('Checking bounces from Postmark since', fromDate, ET);
69
- const client = new postmark.ServerClient(token);
70
-
71
- const toDate = DateTime.now().setZone('EST').toISO({ includeOffset: false });
72
-
73
- if (!toDate) {
74
- console.error('Could not convert date to EST:', new Date());
75
- return;
76
- }
77
-
78
- let offset = 0;
79
- let total = 1;
80
- const count = 500;
81
-
82
- // Sadly the postmark api returns bounces in the wrong order, to make them easier fetchable so we need to fetch them all in one go every time
83
- while (offset < total && offset <= 10000 - count) {
84
- const bounces = await client.getBounces({
85
- fromDate: ET,
86
- toDate,
87
- count,
88
- offset,
89
- });
90
-
91
- if (bounces.TotalCount === 0) {
92
- console.log('No Postmark bounces at this time');
93
- return;
94
- }
95
-
96
- total = bounces.TotalCount;
97
-
98
- console.log('Found', bounces.TotalCount, 'bounces from Postmark');
99
-
100
- let lastId: number | null = null;
101
- const idList = new Set<number>();
102
- let newEventCount = 0;
103
-
104
- for (const bounce of bounces.Bounces) {
105
- idList.add(bounce.ID);
106
- if (lastPostmarkIds.has(bounce.ID)) {
107
- lastId = bounce.ID;
108
- continue;
109
- }
110
- newEventCount += 1;
111
-
112
- // Try to get the organization, if possible, else default to global blocking: "null", which is not visible for an organization, but it is applied
113
- const source = bounce.From;
114
- const organization = source ? await Organization.getByEmail(source) : undefined;
115
- console.log(bounce);
116
-
117
- if (bounce.Type === 'SpamComplaint' || bounce.Type === 'SpamNotification' || bounce.Type === 'VirusNotification') {
118
- console.log('Postmark ' + bounce.Type + ' for: ', bounce.Email, 'from', source, 'organization', organization?.name);
119
- const emailAddress = await EmailAddress.getOrCreate(bounce.Email, organization?.id ?? null);
120
- emailAddress.markedAsSpam = true;
121
- await emailAddress.save();
122
-
123
- if (bounce.Type === 'VirusNotification') {
124
- await saveLog({
125
- email: bounce.Email,
126
- organization,
127
- type: AuditLogType.EmailAddressFraudComplaint,
128
- subType: bounce.Type,
129
- response: bounce.Details,
130
- id: bounce.ID,
131
- subject: bounce.Subject,
132
- sender: bounce.From,
133
- });
134
- }
135
- else {
136
- await saveLog({
137
- email: bounce.Email,
138
- organization,
139
- type: AuditLogType.EmailAddressMarkedAsSpam,
140
- subType: bounce.Type,
141
- response: bounce.Details,
142
- id: bounce.ID,
143
- subject: bounce.Subject,
144
- sender: bounce.From,
145
- });
146
- }
147
- }
148
- else if (bounce.Inactive) {
149
- // Block for everyone, but not visible
150
- console.log('Postmark ' + bounce.Type + ' for: ', bounce.Email, 'from', source, 'organization', organization?.name);
151
- const emailAddress = await EmailAddress.getOrCreate(bounce.Email, organization?.id ?? null);
152
- emailAddress.hardBounce = true;
153
- await emailAddress.save();
154
- await saveLog({
155
- email: bounce.Email,
156
- organization,
157
- type: AuditLogType.EmailAddressHardBounced,
158
- subType: bounce.Type,
159
- response: bounce.Details,
160
- id: bounce.ID,
161
- subject: bounce.Subject,
162
- sender: bounce.From,
163
- });
164
- }
165
- else {
166
- if (bounce.Type === 'SMTPApiError' && bounce.Details.startsWith("ErrorCode: '406'")) {
167
- console.log('Email on Postmark suppression list: ' + bounce.Type + ': ', bounce.Email, 'from', source, 'organization', organization?.name);
168
-
169
- // We've sent a message to an email that is blocked by Postmark
170
- await saveLog({
171
- email: bounce.Email,
172
- organization,
173
- type: AuditLogType.EmailAddressHardBounced,
174
- subType: 'ExternalSuppressionList',
175
- response: bounce.Details,
176
- id: bounce.ID,
177
- subject: '', // bounce.Subject is not correct here for some reason
178
- sender: bounce.From,
179
- });
180
- }
181
- else {
182
- if (bounce.Type === 'SMTPApiError') {
183
- // Log internally
184
- Email.sendWebmaster({
185
- subject: 'Received an SMTPApiError from Postmark',
186
- text: 'We received an SMTPApiError for an e-mail from the organization: ' + organization?.name + '. Please check and adjust if needed.\n' + JSON.stringify(bounce, undefined, 4),
187
- });
188
- }
189
- else {
190
- console.log('Unhandled Postmark ' + bounce.Type + ': ', bounce.Email, 'from', source, 'organization', organization?.name);
191
-
192
- await saveLog({
193
- email: bounce.Email,
194
- organization,
195
- type: AuditLogType.EmailAddressSoftBounced,
196
- subType: bounce.Type,
197
- response: bounce.Details,
198
- id: bounce.ID,
199
- subject: bounce.Subject,
200
- sender: bounce.From,
201
- });
202
- }
203
- }
204
- }
205
-
206
- const bouncedAt = new Date(bounce.BouncedAt);
207
- lastPostmarkCheck = lastPostmarkCheck ? new Date(Math.max(bouncedAt.getTime(), lastPostmarkCheck.getTime())) : bouncedAt;
208
-
209
- lastId = bounce.ID;
210
- }
211
-
212
- if (lastId && newEventCount === 0) {
213
- console.log('Postmark has no new bounces');
214
- // Increase timestamp by one second to avoid refetching it every time
215
- if (lastPostmarkCheck) {
216
- lastPostmarkCheck = new Date(lastPostmarkCheck.getTime() + 1000);
217
- }
218
- }
219
- lastPostmarkIds = idList;
220
-
221
- offset += bounces.Bounces.length;
222
- }
223
- }