@stamhoofd/backend 2.127.1 → 2.128.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.
@@ -0,0 +1,462 @@
1
+ import type { Decoder } from '@simonbackx/simple-encoding';
2
+ import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
3
+ import { Endpoint, Response } from '@simonbackx/simple-endpoints';
4
+ import { SimpleError } from '@simonbackx/simple-errors';
5
+ import type { Organization } from '@stamhoofd/models';
6
+ import { Member, Platform, RateLimiter, sendEmailTemplate } from '@stamhoofd/models';
7
+ import { EmailTemplateType, Recipient, Replacement, SecurityCodeSendMethod, SendMemberSecurityCodeRequest, SendMemberSecurityCodeResponse } from '@stamhoofd/structures';
8
+ import type { Country } from '@stamhoofd/types/Country';
9
+ import { Formatter } from '@stamhoofd/utility';
10
+
11
+ import { Context } from '../../../helpers/Context.js';
12
+ import { SMSService } from '../../../services/SMSService.js';
13
+
14
+ type Params = Record<string, never>;
15
+ type Query = undefined;
16
+ type Body = SendMemberSecurityCodeRequest;
17
+ type ResponseBody = SendMemberSecurityCodeResponse;
18
+
19
+ /**
20
+ * Only allow to send a code a limited number of times for the same member.
21
+ */
22
+ export const memberSecurityCodeSendLimiter = new RateLimiter({
23
+ limits: [
24
+ {
25
+ limit: 3,
26
+ duration: 5 * 60 * 1000,
27
+ },
28
+ {
29
+ limit: 6,
30
+ duration: 12 * 60 * 60 * 1000,
31
+ },
32
+ {
33
+ limit: 9,
34
+ duration: 3 * 24 * 60 * 60 * 1000,
35
+ },
36
+ ],
37
+ });
38
+
39
+ /**
40
+ * Email limit is lower because no reason to send multiple times
41
+ */
42
+ export const memberEmailSecurityCodeSendLimiter = new RateLimiter({
43
+ limits: [
44
+ {
45
+ limit: 1,
46
+ duration: 5 * 60 * 1000,
47
+ },
48
+ {
49
+ limit: 2,
50
+ duration: 12 * 60 * 60 * 1000,
51
+ },
52
+ {
53
+ limit: 3,
54
+ duration: 3 * 24 * 60 * 60 * 1000,
55
+ },
56
+ ],
57
+ });
58
+
59
+ /**
60
+ * Limit per member name + organization, so a birth day cannot be guessed or detected by
61
+ * trying different birth days for a known member name.
62
+ */
63
+ export const nameSecurityCodeSendLimiter = new RateLimiter({
64
+ limits: [
65
+ {
66
+ // Max 10 requests per hour
67
+ limit: 10,
68
+ duration: 60 * 60 * 1000,
69
+ },
70
+ {
71
+ // Max 20 requests per day
72
+ limit: 20,
73
+ duration: 24 * 60 * 60 * 1000,
74
+ },
75
+ ],
76
+ });
77
+
78
+ /**
79
+ * Limit per user to avoid member enumeration attacks.
80
+ */
81
+ export const userSecurityCodeSendLimiter = new RateLimiter({
82
+ limits: [
83
+ {
84
+ // Max 20 requests per hour
85
+ limit: 20,
86
+ duration: 60 * 60 * 1000,
87
+ },
88
+ {
89
+ // Max 30 requests per day
90
+ limit: 30,
91
+ duration: 24 * 60 * 60 * 1000,
92
+ },
93
+ {
94
+ // Max 100 requests per week
95
+ limit: 100,
96
+ duration: 7 * 24 * 60 * 60 * 1000,
97
+ },
98
+ ],
99
+ });
100
+
101
+ /**
102
+ * Limit the number of SMS messages per organization (SMS costs money).
103
+ */
104
+ export const smsOrganizationLimiter = new RateLimiter({
105
+ limits: [
106
+ {
107
+ // Max 25 SMS per day
108
+ limit: 25,
109
+ duration: 24 * 60 * 60 * 1000,
110
+ },
111
+ ],
112
+ });
113
+
114
+ /**
115
+ * Limit how often the provided phone number can be checked against a member's known numbers. Because a
116
+ * wrong number returns an error, this would otherwise be an oracle to enumerate a member's phone numbers.
117
+ */
118
+ export const memberPhoneLookupLimiter = new RateLimiter({
119
+ limits: [
120
+ {
121
+ // Max 10 attempts per hour
122
+ limit: 10,
123
+ duration: 60 * 60 * 1000,
124
+ },
125
+ {
126
+ // Max 30 attempts per day
127
+ limit: 30,
128
+ duration: 24 * 60 * 60 * 1000,
129
+ },
130
+ ],
131
+ });
132
+
133
+ /**
134
+ * Send the security code of a member to the member (or its parents), so an authenticated user can gain
135
+ * access to a member that is already known in the system but not yet linked to their account.
136
+ */
137
+ export class SendMemberSecurityCodeEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
138
+ bodyDecoder = SendMemberSecurityCodeRequest as Decoder<SendMemberSecurityCodeRequest>;
139
+
140
+ protected doesMatch(request: Request): [true, Params] | [false] {
141
+ if (request.method !== 'POST') {
142
+ return [false];
143
+ }
144
+
145
+ const params = Endpoint.parseParameters(request.url, '/members/security-code', {});
146
+
147
+ if (params) {
148
+ return [true, params as Params];
149
+ }
150
+ return [false];
151
+ }
152
+
153
+ async handle(request: DecodedRequest<Params, Query, Body>) {
154
+ const organization = await Context.setOptionalOrganizationScope();
155
+ const { user } = await Context.authenticate();
156
+
157
+ const body = request.body;
158
+
159
+ // Enumeration defense: always consume the per-user rate limit first, before revealing anything.
160
+ try {
161
+ userSecurityCodeSendLimiter.track(user.id, 1);
162
+ } catch (e) {
163
+ throw new SimpleError({
164
+ code: 'too_many_requests',
165
+ message: 'Too many security code requests for this user',
166
+ human: $t(`%ZbM`),
167
+ statusCode: 429,
168
+ });
169
+ }
170
+
171
+ const member = await this.findMember(body, organization);
172
+ if (!member) {
173
+ throw new SimpleError({
174
+ code: 'member_not_found',
175
+ message: 'No member found for the given details',
176
+ human: $t(`%ZbF`),
177
+ statusCode: 404,
178
+ });
179
+ }
180
+
181
+ if (body.method === SecurityCodeSendMethod.SMS) {
182
+ return new Response(await this.sendViaSMS(member, organization, body.tryCount, body.phone));
183
+ }
184
+
185
+ return new Response(await this.sendViaEmail(member, organization));
186
+ }
187
+
188
+ /**
189
+ * Look up the member by id, or by the combination of first name, last name and birth day.
190
+ * String matching relies on the case- and accent-insensitive collation of the database.
191
+ */
192
+ private async findMember(body: SendMemberSecurityCodeRequest, organization: Organization | null): Promise<Member | undefined> {
193
+ if (body.memberId) {
194
+ return (await Member.getByID(body.memberId)) ?? undefined;
195
+ }
196
+
197
+ if (!body.firstName || !body.lastName || !body.birthDay) {
198
+ throw new SimpleError({
199
+ code: 'invalid_field',
200
+ message: 'Either memberId or firstName, lastName and birthDay are required',
201
+ human: $t(`%Zb9`),
202
+ statusCode: 400,
203
+ });
204
+ }
205
+
206
+ // Rate limit by member name + organization (checked before the lookup, so a birth day cannot be
207
+ // guessed or detected by trying different birth days for a known member name).
208
+ const nameKey = (organization?.id ?? 'platform') + ':' + body.firstName.toLowerCase().trim() + ' ' + body.lastName.toLowerCase().trim();
209
+ try {
210
+ nameSecurityCodeSendLimiter.track(nameKey, 1);
211
+ } catch (e) {
212
+ throw new SimpleError({
213
+ code: 'too_many_requests',
214
+ message: 'Too many security code requests for this member name',
215
+ human: $t(`%ZbM`),
216
+ statusCode: 429,
217
+ });
218
+ }
219
+
220
+ const query: { firstName: string; lastName: string; birthDay: string; organizationId?: string } = {
221
+ firstName: body.firstName,
222
+ lastName: body.lastName,
223
+ birthDay: Formatter.dateIso(body.birthDay),
224
+ };
225
+
226
+ // In organization mode members are scoped to a single organization
227
+ if (organization && STAMHOOFD.userMode !== 'platform') {
228
+ query.organizationId = organization.id;
229
+ }
230
+
231
+ const members = await Member.where(query);
232
+ if (members.length === 0) {
233
+ return undefined;
234
+ }
235
+
236
+ // Prefer a member that already has a security code set
237
+ return members.find(m => m.details.securityCode) ?? members[0];
238
+ }
239
+
240
+ /**
241
+ * Make sure the member has a security code, generating one if needed.
242
+ */
243
+ private async ensureSecurityCode(member: Member): Promise<string> {
244
+ if (member.details.securityCode === null) {
245
+ member.details.securityCode = await Member.generateSecurityCode();
246
+ await member.save();
247
+ }
248
+ return member.details.securityCode;
249
+ }
250
+
251
+ private async sendViaEmail(member: Member, organization: Organization | null): Promise<SendMemberSecurityCodeResponse> {
252
+ const emails = Formatter.uniqueArray([
253
+ ...member.details.getMemberEmails(),
254
+ ...member.details.getParentEmails(),
255
+ ...member.details.unverifiedEmails,
256
+ ].map(e => e.toLowerCase().trim()));
257
+
258
+ if (emails.length === 0) {
259
+ throw new SimpleError({
260
+ code: 'no_email',
261
+ message: 'No email address is known for this member',
262
+ human: $t(`%Zb1`),
263
+ statusCode: 400,
264
+ });
265
+ }
266
+
267
+ // Once every 5 minutes per member
268
+ this.trackMemberLimit(member, SecurityCodeSendMethod.Email);
269
+
270
+ const code = await this.ensureSecurityCode(member);
271
+ const formattedCode = Formatter.spaceString(code, 4, '-');
272
+
273
+ await sendEmailTemplate(organization, {
274
+ recipients: emails.map(email => Recipient.create({
275
+ firstName: member.details.firstName,
276
+ lastName: member.details.lastName,
277
+ email,
278
+ replacements: [
279
+ Replacement.create({ token: 'requesterEmail', value: Context.auth.user.email }),
280
+ Replacement.create({ token: 'firstNameMember', value: member.details.firstName }),
281
+ Replacement.create({ token: 'lastNameMember', value: member.details.lastName }),
282
+ Replacement.create({
283
+ token: 'securityCode',
284
+ value: formattedCode,
285
+ html: `<p class="style-code-large">${Formatter.escapeHtml(formattedCode)}</p>`,
286
+ }),
287
+ ],
288
+ })),
289
+ template: {
290
+ type: EmailTemplateType.MemberSecurityCode,
291
+ },
292
+ type: 'transactional',
293
+ });
294
+
295
+ return SendMemberSecurityCodeResponse.create({
296
+ method: SecurityCodeSendMethod.Email,
297
+ maskedRecipient: '',
298
+ });
299
+ }
300
+
301
+ private async sendViaSMS(member: Member, organization: Organization | null, tryCount: number, requestedPhone: string | null): Promise<SendMemberSecurityCodeResponse> {
302
+ // Collect candidate phone numbers, member first, then parents.
303
+ const uniquePhones = member.details.getPhoneNumbersForVerification();
304
+
305
+ if (uniquePhones.length === 0) {
306
+ throw new SimpleError({
307
+ code: 'no_phone',
308
+ message: 'No phone number is known for this member',
309
+ human: $t(`%Zal`),
310
+ statusCode: 400,
311
+ });
312
+ }
313
+
314
+ const defaultCountry = member.details.address?.country ?? organization?.address?.country;
315
+
316
+ let phone: string;
317
+ if (requestedPhone !== null && requestedPhone.trim().length > 0) {
318
+ // The user filled in a phone number: only send to it if we already know it for this member.
319
+ // We never send to an arbitrary number (that would leak the code), and this lookup is rate
320
+ // limited per member so it cannot be used to enumerate a member's phone numbers.
321
+ try {
322
+ memberPhoneLookupLimiter.track(member.id, 1);
323
+ } catch (e) {
324
+ throw new SimpleError({
325
+ code: 'too_many_requests',
326
+ message: 'Too many phone number lookups for this member',
327
+ human: $t(`%ZbL`),
328
+ statusCode: 429,
329
+ });
330
+ }
331
+
332
+ const requestedE164 = this.normalizePhone(requestedPhone, defaultCountry);
333
+ const match = requestedE164 === null
334
+ ? undefined
335
+ : uniquePhones.find(p => this.normalizePhone(p, defaultCountry) === requestedE164);
336
+
337
+ if (!match) {
338
+ throw new SimpleError({
339
+ code: 'phone_not_found',
340
+ message: 'The provided phone number is not linked to this member',
341
+ human: $t(`%Zar`),
342
+ statusCode: 404,
343
+ });
344
+ }
345
+
346
+ phone = match;
347
+ } else {
348
+ // No phone number provided: cycle through the known phone numbers based on the number of previous tries.
349
+ phone = uniquePhones[Math.abs(tryCount) % uniquePhones.length];
350
+ }
351
+
352
+ // Once every 5 minutes per member
353
+ this.trackMemberLimit(member, SecurityCodeSendMethod.SMS);
354
+
355
+ // Max 25 SMS per organization per day
356
+ const organizationKey = member.organizationId ?? organization?.id ?? 'platform';
357
+ try {
358
+ smsOrganizationLimiter.track(organizationKey, 1);
359
+ } catch (e) {
360
+ throw new SimpleError({
361
+ code: 'too_many_sms',
362
+ message: 'The daily SMS limit for this organization has been reached',
363
+ human: $t(`%Zat`),
364
+ statusCode: 429,
365
+ });
366
+ }
367
+
368
+ const code = await this.ensureSecurityCode(member);
369
+ const formattedCode = Formatter.spaceString(code, 4, '-');
370
+
371
+ let message = Context.user?.name && !Context.auth.hasSomePlatformAccess()
372
+ ? $t(`%Zav`, {
373
+ organization: organization?.name ?? (await Platform.getShared()).config.name,
374
+ firstName: member.details.firstName,
375
+ securityCode: formattedCode,
376
+ user: Context.user.name,
377
+ })
378
+ : $t(`%Zao`, {
379
+ organization: organization?.name ?? (await Platform.getShared()).config.name,
380
+ firstName: member.details.firstName,
381
+ securityCode: formattedCode,
382
+ });
383
+
384
+ if (message.length > 160) {
385
+ message = Context.user?.firstName && !Context.auth.hasSomePlatformAccess()
386
+ ? $t(`%Zaz`, {
387
+ organization: organization?.name ?? (await Platform.getShared()).config.name,
388
+ firstName: member.details.firstName,
389
+ securityCode: formattedCode,
390
+ user: Context.user.firstName,
391
+ })
392
+ : $t(`%Zaq`, {
393
+ organization: organization?.name ?? (await Platform.getShared()).config.name,
394
+ firstName: member.details.firstName,
395
+ securityCode: formattedCode,
396
+ });
397
+ }
398
+
399
+ if (message.length > 160) {
400
+ message = Context.user?.firstName && !Context.auth.hasSomePlatformAccess()
401
+ ? $t(`%Zb2`, {
402
+ organization: organization?.name ?? (await Platform.getShared()).config.name,
403
+ firstName: member.details.firstName,
404
+ securityCode: formattedCode,
405
+ user: Context.user.firstName,
406
+ })
407
+ : $t(`%Zas`, {
408
+ organization: organization?.name ?? (await Platform.getShared()).config.name,
409
+ firstName: member.details.firstName,
410
+ securityCode: formattedCode,
411
+ });
412
+ }
413
+
414
+ await SMSService.send({
415
+ to: phone,
416
+ message,
417
+ defaultCountry: member.details.address?.country ?? organization?.address?.country,
418
+ });
419
+
420
+ return SendMemberSecurityCodeResponse.create({
421
+ method: SecurityCodeSendMethod.SMS,
422
+ maskedRecipient: this.maskPhone(phone),
423
+ });
424
+ }
425
+
426
+ private trackMemberLimit(member: Member, method: SecurityCodeSendMethod) {
427
+ try {
428
+ if (method === SecurityCodeSendMethod.Email) {
429
+ memberEmailSecurityCodeSendLimiter.track(member.id, 1);
430
+ } else {
431
+ memberSecurityCodeSendLimiter.track(member.id, 1);
432
+ }
433
+ } catch (e) {
434
+ throw new SimpleError({
435
+ code: 'too_many_requests',
436
+ message: 'A security code was already sent for this member recently',
437
+ human: $t(`%Zaw`),
438
+ statusCode: 429,
439
+ });
440
+ }
441
+ }
442
+
443
+ /**
444
+ * Normalize a phone number to E.164 for comparison, returning null when it cannot be parsed.
445
+ */
446
+ private normalizePhone(phone: string, defaultCountry: Country | undefined): string | null {
447
+ try {
448
+ return SMSService.toE164(phone, defaultCountry);
449
+ } catch (e) {
450
+ return null;
451
+ }
452
+ }
453
+
454
+ /**
455
+ * Return a masked version of a phone number that is safe to show to the user, e.g. "•••• 89".
456
+ */
457
+ private maskPhone(phone: string): string {
458
+ const digits = phone.replace(/\D/g, '');
459
+ const last = digits.substring(Math.max(0, digits.length - 2));
460
+ return '•••• ' + last;
461
+ }
462
+ }
@@ -39,6 +39,8 @@ describe('Endpoint.PatchUserMembersEndpoint', () => {
39
39
  birthDay,
40
40
  generateData: true,
41
41
  }).create();
42
+ existingMember.details.nationalRegisterNumber = '123454123';
43
+ await existingMember.save();
42
44
 
43
45
  const token = await Token.createToken(user);
44
46
 
@@ -6,7 +6,7 @@ import { SimpleError } from '@simonbackx/simple-errors';
6
6
  import type { Group, Registration } from '@stamhoofd/models';
7
7
  import { Document, Member, RateLimiter } from '@stamhoofd/models';
8
8
  import type { MemberDetails, MembersBlob } from '@stamhoofd/structures';
9
- import { MemberWithRegistrationsBlob } from '@stamhoofd/structures';
9
+ import { BooleanStatus, MemberWithRegistrationsBlob } from '@stamhoofd/structures';
10
10
 
11
11
  import type { OneToManyRelation } from '@simonbackx/simple-database';
12
12
  import { AuthenticatedStructures } from '../../../helpers/AuthenticatedStructures.js';
@@ -112,6 +112,7 @@ export class PatchUserMembersEndpoint extends Endpoint<Params, Query, Body, Resp
112
112
  const previousUitpasNumber = member.details.uitpasNumberDetails?.uitpasNumber ?? null;
113
113
 
114
114
  const originalReviewTimes = member.details.reviewTimes;
115
+
115
116
  member.details.patchOrPut(struct.details);
116
117
 
117
118
  if (struct.details.uitpasNumberDetails || didUitpasReviewChange(struct.details.reviewTimes, originalReviewTimes)) {
@@ -120,6 +121,20 @@ export class PatchUserMembersEndpoint extends Endpoint<Params, Query, Body, Resp
120
121
 
121
122
  member.details.cleanData();
122
123
  this.throwIfInvalidDetails(member.details);
124
+
125
+ // give the parents access to the member they are patching if they would loose access
126
+ if (
127
+ // if the parents have no access after the patch
128
+ !member.details.calculatedParentsHaveAccess
129
+ // and the parent access is not yet configured
130
+ && member.details.parentsHaveAccess === null
131
+ // and the user is one of the parents
132
+ && member.details.parents.find(p => p.email === user.email)) {
133
+ // grant parents access
134
+ member.details.parentsHaveAccess = BooleanStatus.create({
135
+ value: true,
136
+ });
137
+ }
123
138
  }
124
139
 
125
140
  if (!member.details) {
@@ -155,12 +155,9 @@ export class PatchWebshopOrdersEndpoint extends Endpoint<Params, Query, Body, Re
155
155
  payment.method = struct.data.paymentMethod;
156
156
 
157
157
  // quick throw to prevent payment from being saved
158
- if (payment.method === PaymentMethod.Transfer && !webshop.meta.paymentConfiguration.transferSettings.iban) {
159
- throw new SimpleError({
160
- code: 'missing_iban',
161
- message: 'Missing IBAN',
162
- human: $t(`%w2`),
163
- });
158
+ if (payment.method === PaymentMethod.Transfer) {
159
+ // will throw if iban is missing
160
+ order.getTransferSettings({ shouldThrowIfNoIban: true });
164
161
  }
165
162
 
166
163
  payment.status = PaymentStatus.Created;
@@ -5,25 +5,8 @@ import { SQL } from '@stamhoofd/sql';
5
5
  import type { MemberDetails, PermissionRole } from '@stamhoofd/structures';
6
6
  import { AuditLogSource, Permissions, ReceivableBalanceType, UserPermissions } from '@stamhoofd/structures';
7
7
  import { Formatter } from '@stamhoofd/utility';
8
- import basex from 'base-x';
9
- import crypto from 'crypto';
10
8
  import { AuditLogService } from '../services/AuditLogService.js';
11
9
 
12
- const ALPHABET = '123456789ABCDEFGHJKMNPQRSTUVWXYZ'; // Note: we removed 0, O, I and l to make it easier for humans
13
- const customBase = basex(ALPHABET);
14
-
15
- async function randomBytes(size: number): Promise<Buffer> {
16
- return new Promise((resolve, reject) => {
17
- crypto.randomBytes(size, (err: Error | null, buf: Buffer) => {
18
- if (err) {
19
- reject(err);
20
- return;
21
- }
22
- resolve(buf);
23
- });
24
- });
25
- }
26
-
27
10
  export class MemberUserSyncerStatic {
28
11
  /**
29
12
  * Call when:
@@ -97,12 +80,10 @@ export class MemberUserSyncerStatic {
97
80
  }
98
81
 
99
82
  // Generate security code (only for userMode platform)
100
- if (STAMHOOFD.userMode !== 'organization' && member.details.securityCode === null) {
83
+ if (member.details.securityCode === null) {
101
84
  console.log('Generating security code for member ' + member.id);
102
85
 
103
- const length = 16;
104
- const code = customBase.encode(await randomBytes(100)).toUpperCase().substring(0, length);
105
- member.details.securityCode = code;
86
+ member.details.securityCode = await Member.generateSecurityCode();
106
87
  await member.save();
107
88
  }
108
89
  });
@@ -0,0 +1,3 @@
1
+ INSERT INTO `email_templates` (`id`, `subject`, `groupId`, `webshopId`, `organizationId`, `type`, `text`, `html`, `json`, `updatedAt`, `createdAt`) VALUES
2
+ ('7c9e6a41-2b8d-4f3a-9e21-5a1c3b4d5e6f', 'Beveiligingscode voor {{firstNameMember}}', NULL, NULL, NULL, 'MemberSecurityCode', '{{greeting}}\n\nJij of een gezinslid ({{requesterEmail}}) vroeg de beveiligingscode op van {{firstNameMember}} bij {{organizationName}}. Met deze code kan je toegang krijgen tot dit lid:\n\n{{securityCode}}Heb je dit niet aangevraagd? Dan kan je deze e-mail veilig negeren.\n\nMet vriendelijke groeten,\n{{organizationName}}\n\nIk wil deze mails niet meer ontvangen ({{unsubscribeUrl}})', '<!DOCTYPE html>\n<html>\n\n<head>\n<meta charset=\"utf-8\" />\n<meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\" />\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1.0\" />\n<title>Beveiligingscode voor {{firstNameMember}}</title>\n<style type=\"text/css\">body {\n color: #000716;\n color: var(--color-dark, #000716);\n font-family: -apple-system-body, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\";\n font-size: 12pt;\n line-height: 1.4;\n}\n\np {\n margin: 0;\n padding: 0;\n line-height: 1.4;\n}\n\np.description {\n color: var(--color-gray-4, #5e5e5e);\n font-size: 10pt;\n}\np.description a, p.description a:link, p.description a:visited, p.description a:active, p.description a:hover {\n text-decoration: underline;\n color: var(--color-gray-4, #5e5e5e);\n}\n\nstrong {\n font-weight: bold;\n}\n\nem {\n font-style: italic;\n}\n\nh1 {\n font-size: 30px;\n font-weight: bold;\n line-height: 1.2;\n}\n@media (max-width: 350px) {\n h1 {\n font-size: 24px;\n }\n}\nh1 {\n margin: 0;\n padding: 0;\n}\n\nh2 {\n font-size: 20px;\n line-height: 1.2;\n font-weight: bold;\n margin: 0;\n padding: 0;\n}\n\nh3 {\n font-size: 16px;\n line-height: 1.2;\n font-weight: bold;\n margin: 0;\n padding: 0;\n}\n\nh4 {\n line-height: 1.2;\n font-weight: 500;\n margin: 0;\n padding: 0;\n}\n\nol, ul {\n list-style-position: outside;\n padding-left: 30px;\n}\n\nhr {\n height: 1px;\n background: var(--color-border, var(--color-gray-2, #dcdcdc));\n border-radius: 1px;\n padding: 0;\n margin: 20px 0;\n outline: none;\n border: 0;\n}\n\n.button {\n touch-action: inherit;\n user-select: auto;\n cursor: pointer;\n display: inline-block !important;\n line-height: 42px;\n font-size: 16px;\n font-weight: bold;\n text-box-trim: none;\n}\n.button:active {\n transform: none;\n}\n\nimg {\n max-width: 100%;\n height: auto;\n}\n\na, a:link, a:visited, a:active, a:hover {\n text-decoration: underline;\n color: blue;\n}\n\n.email-data-table {\n width: 100%;\n border-collapse: collapse;\n}\n.email-data-table th, .email-data-table td {\n text-align: left;\n padding: 10px 10px 10px 0;\n border-bottom: 1px solid var(--color-border, var(--color-gray-2, #dcdcdc));\n vertical-align: middle;\n}\n.email-data-table th:last-child, .email-data-table td:last-child {\n text-align: right;\n padding-right: 0;\n}\n.email-data-table td.price {\n white-space: nowrap;\n}\n.email-data-table thead {\n font-weight: bold;\n}\n.email-data-table thead th {\n font-size: 10pt;\n}\n.email-data-table h4 ~ p {\n padding-top: 3px;\n opacity: 0.8;\n font-size: 11pt;\n}\n\n.style-inline-code {\n font-family: monospace;\n white-space: pre-wrap;\n display: inline-block;\n letter-spacing: 1%;\n}\n\n.style-code-large {\n font-family: monospace;\n white-space: pre-wrap;\n font-size: 16pt;\n letter-spacing: 2px;\n background: #f2f2f2;\n padding: 6px 10px;\n border-radius: 6px;\n display: inline-block;\n}\n\n.email-style-description-small {\n font-size: 14px;\n line-height: 1.5;\n font-weight: normal;\n color: var(--color-gray-4, #5e5e5e);\n font-variation-settings: \"opsz\" 19;\n}\n\n.email-style-title-list {\n font-size: 16px;\n line-height: 1.3;\n font-weight: 500;\n}\n.email-style-title-list + p {\n padding-top: 3px;\n}\n\n.email-style-title-prefix-list {\n font-size: 11px;\n line-height: 1.5;\n font-weight: bold;\n color: {{primaryColor}};\n text-transform: uppercase;\n margin-bottom: 3px;\n}\n.email-style-title-prefix-list.error {\n color: #f0153d;\n}\n\n.email-style-price-base, .email-style-discount-price, .email-style-discount-old-price, .email-style-price {\n font-size: 15px;\n line-height: 1.4;\n font-weight: 500;\n font-variant-numeric: tabular-nums;\n}\n.email-style-price-base.disabled, .disabled.email-style-discount-price, .disabled.email-style-discount-old-price, .disabled.email-style-price {\n opacity: 0.6;\n}\n.email-style-price-base.negative, .negative.email-style-discount-price, .negative.email-style-discount-old-price, .negative.email-style-price {\n color: #f0153d;\n}\n\n.email-style-price {\n font-weight: bold;\n color: {{primaryColor}};\n}\n\n.email-style-discount-old-price {\n text-decoration: line-through;\n color: var(--color-gray-4, #5e5e5e);\n}\n\n.email-style-discount-price {\n font-weight: bold;\n color: #ff4747;\n margin-left: 5px;\n}\n\n.pre-wrap {\n white-space: pre-wrap;\n} hr {height: 2px;background: #e7e7e7; border-radius: 1px; padding: 0; margin: 20px 0; outline: none; border: 0;} .button.primary { margin: 0; text-decoration: none; font-size: 16px; font-weight: bold; color: {{primaryColorContrast}}; padding: 0 27px; line-height: 42px; background: {{primaryColor}}; text-align: center; border-radius: 7px; touch-action: manipulation; display: inline-block; transition: 0.2s transform, 0.2s opacity; } .button.primary:active { transform: scale(0.95, 0.95); } .inline-link, .inline-link:link, .inline-link:visited, .inline-link:active, .inline-link:hover { margin: 0; text-decoration: underline; font-size: inherit; font-weight: inherit; color: inherit; touch-action: manipulation; } .inline-link:active { opacity: 0.5; } .description { color: #5e5e5e; } </style>\n</head>\n\n<body>\n<p style=\"margin: 0; padding: 0; line-height: 1.4;\">{{greeting}}</p><p style=\"margin: 0; padding: 0; line-height: 1.4;\"><br></p><p style=\"margin: 0; padding: 0; line-height: 1.4;\">Jij of een gezinslid ({{requesterEmail}}) vroeg de beveiligingscode op van {{firstNameMember}} bij {{organizationName}}. Met deze code kan je toegang krijgen tot dit lid:</p><p style=\"margin: 0; padding: 0; line-height: 1.4;\"><br></p><div data-type=\"smartVariableBlock\" data-id=\"securityCode\">{{securityCode}}</div><p class=\"description\" style=\"color: #5e5e5e;\">Heb je dit niet aangevraagd? Dan kan je deze e-mail veilig negeren.</p><p style=\"margin: 0; padding: 0; line-height: 1.4;\"><br></p><p style=\"margin: 0; padding: 0; line-height: 1.4;\">Met vriendelijke groeten,</p><p style=\"margin: 0; padding: 0; line-height: 1.4;\">{{organizationName}}</p><p style=\"margin: 0; padding: 0; line-height: 1.4;\"><br></p><hr style=\"height: 2px;background: #e7e7e7; border-radius: 1px; padding: 0; margin: 20px 0; outline: none; border: 0;\"><p style=\"margin: 0; padding: 0; line-height: 1.4;\"><a class=\"inline-link\" href=\"{{unsubscribeUrl}}\" target=\"\" style=\"margin: 0; text-decoration: underline; font-size: inherit; font-weight: inherit; color: inherit; touch-action: manipulation;\">Ik wil deze mails niet meer ontvangen</a></p><p style=\"margin: 0; padding: 0; line-height: 1.4;\"><br></p>\n</body>\n\n</html>', '{\"value\": {\"type\": \"doc\", \"content\": [{\"type\": \"paragraph\", \"content\": [{\"type\": \"smartVariable\", \"attrs\": {\"id\": \"greeting\"}}]}, {\"type\": \"paragraph\"}, {\"type\": \"paragraph\", \"content\": [{\"text\": \"Jij of een gezinslid (\", \"type\": \"text\"}, {\"type\": \"smartVariable\", \"attrs\": {\"id\": \"requesterEmail\"}}, {\"text\": \") vroeg de beveiligingscode op van \", \"type\": \"text\"}, {\"type\": \"smartVariable\", \"attrs\": {\"id\": \"firstNameMember\"}}, {\"text\": \" bij \", \"type\": \"text\"}, {\"type\": \"smartVariable\", \"attrs\": {\"id\": \"organizationName\"}}, {\"text\": \". Met deze code kan je toegang krijgen tot dit lid:\", \"type\": \"text\"}]}, {\"type\": \"paragraph\"}, {\"type\": \"smartVariableBlock\", \"attrs\": {\"id\": \"securityCode\"}}, {\"type\": \"descriptiveText\", \"content\": [{\"text\": \"Heb je dit niet aangevraagd? Dan kan je deze e-mail veilig negeren.\", \"type\": \"text\"}]}, {\"type\": \"paragraph\"}, {\"type\": \"paragraph\", \"content\": [{\"text\": \"Met vriendelijke groeten,\", \"type\": \"text\"}]}, {\"type\": \"paragraph\", \"content\": [{\"type\": \"smartVariable\", \"attrs\": {\"id\": \"organizationName\"}}]}, {\"type\": \"paragraph\"}, {\"type\": \"horizontalRule\"}, {\"type\": \"paragraph\", \"content\": [{\"type\": \"smartButtonInline\", \"attrs\": {\"id\": \"unsubscribeUrl\"}, \"content\": [{\"text\": \"Ik wil deze mails niet meer ontvangen\", \"type\": \"text\"}]}]}, {\"type\": \"paragraph\"}]}, \"version\": 401}', '2026-07-03 15:41:24', '2026-07-03 15:41:24')
3
+ ON DUPLICATE KEY UPDATE id=id;
@@ -6,20 +6,15 @@ import { MemberUserSyncer } from '../helpers/MemberUserSyncer.js';
6
6
  import { allSettledButThrowFirst, SeedTools } from '../helpers/SeedTools.js';
7
7
 
8
8
  export default new Migration(async () => {
9
- if (STAMHOOFD.environment == 'test') {
9
+ if (STAMHOOFD.environment === 'test') {
10
10
  console.log('skipped in tests');
11
11
  return;
12
12
  }
13
13
 
14
- if (STAMHOOFD.platformName.toLowerCase() !== 'stamhoofd') {
15
- console.log('skipped for platform (only runs for Stamhoofd): ' + STAMHOOFD.platformName);
16
- return;
17
- }
18
-
19
14
  const result = await logger.setContext({ tags: ['silent-seed', 'seed'] }, async () => {
20
15
  return await SeedTools.loopBatched({
21
16
  query: Member.select('id'),
22
- batchSize: 500,
17
+ batchSize: 50,
23
18
  batchAction: async (rawMembers) => {
24
19
  const membersWithRegistrations = await Member.getBlobByIds(...rawMembers.map(m => m.id));
25
20
 
@@ -0,0 +1,48 @@
1
+ import { STExpect, TestUtils } from '@stamhoofd/test-utils';
2
+ import { initSMSApi } from '../../tests/init/index.js';
3
+ import { SMSService } from './SMSService.js';
4
+
5
+ describe('SMSService', () => {
6
+ describe('configuration', () => {
7
+ test('is configured when GATEWAYAPI_TOKEN is set', () => {
8
+ // The test environment configures GATEWAYAPI_TOKEN by default
9
+ expect(SMSService.isConfigured).toBe(true);
10
+ });
11
+
12
+ test('is not configured when GATEWAYAPI_TOKEN is missing', () => {
13
+ TestUtils.setEnvironment('GATEWAYAPI_TOKEN', undefined);
14
+ expect(SMSService.isConfigured).toBe(false);
15
+ });
16
+ });
17
+
18
+ describe('phone number formatting', () => {
19
+ test('converts to E.164 and msisdn', () => {
20
+ expect(SMSService.toE164('+32 470 12 34 56')).toBe('+32470123456');
21
+ expect(SMSService.toMsisdn('+32 470 12 34 56')).toBe(32470123456);
22
+ });
23
+
24
+ test('throws for invalid phone numbers', () => {
25
+ expect(() => SMSService.toE164('not-a-number')).toThrow(STExpect.errorWithCode('invalid_phone'));
26
+ });
27
+ });
28
+
29
+ describe('sending', () => {
30
+ test('sends via GatewayAPI', async () => {
31
+ const mocker = initSMSApi();
32
+
33
+ await SMSService.send({ to: '+32 470 12 34 56', message: 'Your code is 1234' });
34
+
35
+ expect(mocker.sentMessages.length).toBe(1);
36
+ expect(mocker.lastMessage!.recipient).toBe(32470123456);
37
+ expect(mocker.lastMessage!.message).toBe('Your code is 1234');
38
+ });
39
+
40
+ test('throws sms_not_configured when GATEWAYAPI_TOKEN is missing', async () => {
41
+ TestUtils.setEnvironment('GATEWAYAPI_TOKEN', undefined);
42
+
43
+ await expect(SMSService.send({ to: '+32470123456', message: 'hi' }))
44
+ .rejects
45
+ .toThrow(STExpect.errorWithCode('sms_not_configured'));
46
+ });
47
+ });
48
+ });