@stamhoofd/backend 2.127.1 → 2.129.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,432 @@
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 { AuditLog, Member, Platform, RateLimiter, sendEmailTemplate } from '@stamhoofd/models';
7
+ import { AuditLogReplacement, AuditLogReplacementType, AuditLogSource, AuditLogType, 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 user to avoid member enumeration attacks.
61
+ */
62
+ export const userSecurityCodeSendLimiter = new RateLimiter({
63
+ limits: [
64
+ {
65
+ // Max 20 requests per hour
66
+ limit: 20,
67
+ duration: 60 * 60 * 1000,
68
+ },
69
+ {
70
+ // Max 30 requests per day
71
+ limit: 30,
72
+ duration: 24 * 60 * 60 * 1000,
73
+ },
74
+ {
75
+ // Max 100 requests per week
76
+ limit: 100,
77
+ duration: 7 * 24 * 60 * 60 * 1000,
78
+ },
79
+ ],
80
+ });
81
+
82
+ /**
83
+ * Limit the number of SMS messages per organization (SMS costs money).
84
+ */
85
+ export const smsOrganizationLimiter = new RateLimiter({
86
+ limits: [
87
+ {
88
+ // Max 25 SMS per day
89
+ limit: 25,
90
+ duration: 24 * 60 * 60 * 1000,
91
+ },
92
+ ],
93
+ });
94
+
95
+ /**
96
+ * Limit how often the provided phone number can be checked against a member's known numbers. Because a
97
+ * wrong number returns an error, this would otherwise be an oracle to enumerate a member's phone numbers.
98
+ */
99
+ export const memberPhoneLookupLimiter = new RateLimiter({
100
+ limits: [
101
+ {
102
+ // Max 10 attempts per hour
103
+ limit: 10,
104
+ duration: 60 * 60 * 1000,
105
+ },
106
+ {
107
+ // Max 30 attempts per day
108
+ limit: 30,
109
+ duration: 24 * 60 * 60 * 1000,
110
+ },
111
+ ],
112
+ });
113
+
114
+ /**
115
+ * Send the security code of a member to the member (or its parents), so an authenticated user can gain
116
+ * access to a member that is already known in the system but not yet linked to their account.
117
+ */
118
+ export class SendMemberSecurityCodeEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
119
+ bodyDecoder = SendMemberSecurityCodeRequest as Decoder<SendMemberSecurityCodeRequest>;
120
+
121
+ protected doesMatch(request: Request): [true, Params] | [false] {
122
+ if (request.method !== 'POST') {
123
+ return [false];
124
+ }
125
+
126
+ const params = Endpoint.parseParameters(request.url, '/members/security-code', {});
127
+
128
+ if (params) {
129
+ return [true, params as Params];
130
+ }
131
+ return [false];
132
+ }
133
+
134
+ async handle(request: DecodedRequest<Params, Query, Body>) {
135
+ const organization = await Context.setOptionalOrganizationScope();
136
+ const { user } = await Context.authenticate();
137
+
138
+ const body = request.body;
139
+
140
+ // Enumeration defense: always consume the per-user rate limit first, before revealing anything.
141
+ try {
142
+ userSecurityCodeSendLimiter.track(user.id, 1);
143
+ } catch (e) {
144
+ throw new SimpleError({
145
+ code: 'too_many_requests',
146
+ message: 'Too many security code requests for this user',
147
+ human: $t(`%ZbM`),
148
+ statusCode: 429,
149
+ });
150
+ }
151
+
152
+ const member = await this.findMember(body, organization);
153
+ if (!member) {
154
+ throw new SimpleError({
155
+ code: 'member_not_found',
156
+ message: 'No member found for the given details',
157
+ human: $t(`%ZbF`),
158
+ statusCode: 404,
159
+ });
160
+ }
161
+
162
+ if (body.method === SecurityCodeSendMethod.SMS) {
163
+ return new Response(await this.sendViaSMS(member, organization, body.tryCount, body.phone));
164
+ }
165
+
166
+ return new Response(await this.sendViaEmail(member, organization));
167
+ }
168
+
169
+ /**
170
+ * Look up the member by id, or by the combination of first name, last name and birth day.
171
+ * String matching relies on the case- and accent-insensitive collation of the database.
172
+ */
173
+ private async findMember(body: SendMemberSecurityCodeRequest, organization: Organization | null): Promise<Member | undefined> {
174
+ return (await Member.getByID(body.memberId)) ?? undefined;
175
+ }
176
+
177
+ /**
178
+ * Make sure the member has a security code, generating one if needed.
179
+ */
180
+ private async ensureSecurityCode(member: Member): Promise<string> {
181
+ if (member.details.securityCode === null) {
182
+ member.details.securityCode = await Member.generateSecurityCode();
183
+ await member.save();
184
+ }
185
+ return member.details.securityCode;
186
+ }
187
+
188
+ private async sendViaEmail(member: Member, organization: Organization | null): Promise<SendMemberSecurityCodeResponse> {
189
+ const emails = Formatter.uniqueArray([
190
+ ...member.details.getMemberEmails(),
191
+ ...member.details.getParentEmails(),
192
+ ...member.details.unverifiedEmails,
193
+ ].map(e => e.toLowerCase().trim()));
194
+
195
+ if (emails.length === 0) {
196
+ throw new SimpleError({
197
+ code: 'no_email',
198
+ message: 'No email address is known for this member',
199
+ human: $t(`%Zb1`),
200
+ statusCode: 400,
201
+ });
202
+ }
203
+
204
+ // Once every 5 minutes per member
205
+ this.trackMemberLimit(member, SecurityCodeSendMethod.Email);
206
+
207
+ const code = await this.ensureSecurityCode(member);
208
+ const formattedCode = Formatter.spaceString(code, 4, '-');
209
+
210
+ await sendEmailTemplate(organization, {
211
+ recipients: emails.map(email => Recipient.create({
212
+ firstName: member.details.firstName,
213
+ lastName: member.details.lastName,
214
+ email,
215
+ replacements: [
216
+ Replacement.create({ token: 'requesterEmail', value: Context.auth.user.email }),
217
+ Replacement.create({ token: 'firstNameMember', value: member.details.firstName }),
218
+ Replacement.create({ token: 'lastNameMember', value: member.details.lastName }),
219
+ Replacement.create({
220
+ token: 'securityCode',
221
+ value: formattedCode,
222
+ html: `<p class="style-code-large">${Formatter.escapeHtml(formattedCode)}</p>`,
223
+ }),
224
+ ],
225
+ })),
226
+ template: {
227
+ type: EmailTemplateType.MemberSecurityCode,
228
+ },
229
+ type: 'transactional',
230
+ });
231
+
232
+ await this.logSecurityCodeRequested(member, SecurityCodeSendMethod.Email, emails.join(', '));
233
+
234
+ return SendMemberSecurityCodeResponse.create({
235
+ method: SecurityCodeSendMethod.Email,
236
+ maskedRecipient: '',
237
+ });
238
+ }
239
+
240
+ private async sendViaSMS(member: Member, organization: Organization | null, tryCount: number, requestedPhone: string | null): Promise<SendMemberSecurityCodeResponse> {
241
+ // Collect candidate phone numbers, member first, then parents.
242
+ const uniquePhones = member.details.getPhoneNumbersForVerification();
243
+
244
+ if (uniquePhones.length === 0) {
245
+ throw new SimpleError({
246
+ code: 'no_phone',
247
+ message: 'No phone number is known for this member',
248
+ human: $t(`%Zal`),
249
+ statusCode: 400,
250
+ });
251
+ }
252
+
253
+ const defaultCountry = member.details.address?.country ?? organization?.address?.country;
254
+
255
+ let phone: string;
256
+ if (requestedPhone !== null && requestedPhone.trim().length > 0) {
257
+ // The user filled in a phone number: only send to it if we already know it for this member.
258
+ // We never send to an arbitrary number (that would leak the code), and this lookup is rate
259
+ // limited per member so it cannot be used to enumerate a member's phone numbers.
260
+ try {
261
+ memberPhoneLookupLimiter.track(member.id, 1);
262
+ } catch (e) {
263
+ throw new SimpleError({
264
+ code: 'too_many_requests',
265
+ message: 'Too many phone number lookups for this member',
266
+ human: $t(`%ZbL`),
267
+ statusCode: 429,
268
+ });
269
+ }
270
+
271
+ const requestedE164 = this.normalizePhone(requestedPhone, defaultCountry);
272
+ const match = requestedE164 === null
273
+ ? undefined
274
+ : uniquePhones.find(p => this.normalizePhone(p, defaultCountry) === requestedE164);
275
+
276
+ if (!match) {
277
+ throw new SimpleError({
278
+ code: 'phone_not_found',
279
+ message: 'The provided phone number is not linked to this member',
280
+ human: $t(`%Zar`),
281
+ statusCode: 404,
282
+ });
283
+ }
284
+
285
+ phone = match;
286
+ } else {
287
+ // No phone number provided: cycle through the known phone numbers based on the number of previous tries.
288
+ phone = uniquePhones[Math.abs(tryCount) % uniquePhones.length];
289
+ }
290
+
291
+ // Once every 5 minutes per member
292
+ this.trackMemberLimit(member, SecurityCodeSendMethod.SMS);
293
+
294
+ // Max 25 SMS per organization per day
295
+ const organizationKey = member.organizationId ?? organization?.id ?? 'platform';
296
+ try {
297
+ smsOrganizationLimiter.track(organizationKey, 1);
298
+ } catch (e) {
299
+ throw new SimpleError({
300
+ code: 'too_many_sms',
301
+ message: 'The daily SMS limit for this organization has been reached',
302
+ human: $t(`%Zat`),
303
+ statusCode: 429,
304
+ });
305
+ }
306
+
307
+ const code = await this.ensureSecurityCode(member);
308
+ const formattedCode = Formatter.spaceString(code, 4, '-');
309
+
310
+ let message = Context.user?.name && !Context.auth.hasSomePlatformAccess()
311
+ ? $t(`%Zav`, {
312
+ organization: organization?.name ?? (await Platform.getShared()).config.name,
313
+ firstName: member.details.firstName,
314
+ securityCode: formattedCode,
315
+ user: Context.user.name,
316
+ })
317
+ : $t(`%Zao`, {
318
+ organization: organization?.name ?? (await Platform.getShared()).config.name,
319
+ firstName: member.details.firstName,
320
+ securityCode: formattedCode,
321
+ });
322
+
323
+ if (message.length > 160) {
324
+ message = Context.user?.firstName && !Context.auth.hasSomePlatformAccess()
325
+ ? $t(`%Zaz`, {
326
+ organization: organization?.name ?? (await Platform.getShared()).config.name,
327
+ firstName: member.details.firstName,
328
+ securityCode: formattedCode,
329
+ user: Context.user.firstName,
330
+ })
331
+ : $t(`%Zaq`, {
332
+ organization: organization?.name ?? (await Platform.getShared()).config.name,
333
+ firstName: member.details.firstName,
334
+ securityCode: formattedCode,
335
+ });
336
+ }
337
+
338
+ if (message.length > 160) {
339
+ message = Context.user?.firstName && !Context.auth.hasSomePlatformAccess()
340
+ ? $t(`%Zb2`, {
341
+ organization: organization?.name ?? (await Platform.getShared()).config.name,
342
+ firstName: member.details.firstName,
343
+ securityCode: formattedCode,
344
+ user: Context.user.firstName,
345
+ })
346
+ : $t(`%Zas`, {
347
+ organization: organization?.name ?? (await Platform.getShared()).config.name,
348
+ firstName: member.details.firstName,
349
+ securityCode: formattedCode,
350
+ });
351
+ }
352
+
353
+ await SMSService.send({
354
+ to: phone,
355
+ message,
356
+ defaultCountry: member.details.address?.country ?? organization?.address?.country,
357
+ });
358
+
359
+ const maskedRecipient = this.maskPhone(phone);
360
+ await this.logSecurityCodeRequested(member, SecurityCodeSendMethod.SMS, maskedRecipient);
361
+
362
+ return SendMemberSecurityCodeResponse.create({
363
+ method: SecurityCodeSendMethod.SMS,
364
+ maskedRecipient,
365
+ });
366
+ }
367
+
368
+ /**
369
+ * Record an audit log entry so it is traceable who requested a member's security code, how and to where.
370
+ */
371
+ private async logSecurityCodeRequested(member: Member, method: SecurityCodeSendMethod, maskedRecipient: string) {
372
+ const log = new AuditLog();
373
+
374
+ // A member can belong to multiple organizations, so this is best-effort (mainly visible in the admin panel).
375
+ log.organizationId = member.organizationId;
376
+ log.type = AuditLogType.MemberSecurityCodeRequested;
377
+ log.source = AuditLogSource.User;
378
+ log.userId = Context.user?.id ?? null;
379
+ log.objectId = member.id;
380
+ log.replacements = new Map([
381
+ ['m', AuditLogReplacement.create({
382
+ value: member.details.name,
383
+ type: AuditLogReplacementType.Member,
384
+ id: member.id,
385
+ })],
386
+ ['method', AuditLogReplacement.create({
387
+ value: method === SecurityCodeSendMethod.SMS ? 'SMS' : 'e-mail',
388
+ })],
389
+ ['recipient', AuditLogReplacement.create({
390
+ value: maskedRecipient,
391
+ })],
392
+ ]);
393
+ await log.save();
394
+ }
395
+
396
+ private trackMemberLimit(member: Member, method: SecurityCodeSendMethod) {
397
+ try {
398
+ if (method === SecurityCodeSendMethod.Email) {
399
+ memberEmailSecurityCodeSendLimiter.track(member.id, 1);
400
+ } else {
401
+ memberSecurityCodeSendLimiter.track(member.id, 1);
402
+ }
403
+ } catch (e) {
404
+ throw new SimpleError({
405
+ code: 'too_many_requests',
406
+ message: 'A security code was already sent for this member recently',
407
+ human: $t(`%Zaw`),
408
+ statusCode: 429,
409
+ });
410
+ }
411
+ }
412
+
413
+ /**
414
+ * Normalize a phone number to E.164 for comparison, returning null when it cannot be parsed.
415
+ */
416
+ private normalizePhone(phone: string, defaultCountry: Country | undefined): string | null {
417
+ try {
418
+ return SMSService.toE164(phone, defaultCountry);
419
+ } catch (e) {
420
+ return null;
421
+ }
422
+ }
423
+
424
+ /**
425
+ * Return a masked version of a phone number that is safe to show to the user, e.g. "•••• 89".
426
+ */
427
+ private maskPhone(phone: string): string {
428
+ const digits = phone.replace(/\D/g, '');
429
+ const last = digits.substring(Math.max(0, digits.length - 2));
430
+ return '•• •• ' + last;
431
+ }
432
+ }
@@ -1,6 +1,5 @@
1
1
  import type { AutoEncoderPatchType } from '@simonbackx/simple-encoding';
2
2
  import type { MemberDetails } from '@stamhoofd/structures';
3
- import { MemberWithRegistrationsBlob } from '@stamhoofd/structures';
4
3
 
5
4
  /**
6
5
  * Returns true when either the firstname, lastname or birthday has changed
@@ -9,6 +9,7 @@ import { STExpect, TestUtils } from '@stamhoofd/test-utils';
9
9
  import { testServer } from '../../../../tests/helpers/TestServer.js';
10
10
  import { initUitpasApi } from '../../../../tests/init/index.js';
11
11
  import { PatchUserMembersEndpoint } from './PatchUserMembersEndpoint.js';
12
+ import { MemberUserSyncer } from '../../../helpers/MemberUserSyncer.js';
12
13
 
13
14
  const baseUrl = `/members`;
14
15
  const endpoint = new PatchUserMembersEndpoint();
@@ -39,6 +40,8 @@ describe('Endpoint.PatchUserMembersEndpoint', () => {
39
40
  birthDay,
40
41
  generateData: true,
41
42
  }).create();
43
+ existingMember.details.nationalRegisterNumber = '123454123';
44
+ await existingMember.save();
42
45
 
43
46
  const token = await Token.createToken(user);
44
47
 
@@ -99,6 +102,9 @@ describe('Endpoint.PatchUserMembersEndpoint', () => {
99
102
  expect(member.details.birthDay).toEqual(newBirthDay);
100
103
  expect(member.details.email).toBe('anewemail@example.com'); // this has been merged
101
104
  expect(member.details.alternativeEmails).toHaveLength(0);
105
+
106
+ // Check access
107
+ expect(member.users.filter(u => u.id === user.id)).toHaveLength(1);
102
108
  });
103
109
 
104
110
  test('A duplicate member with existing registrations returns those registrations after a merge', async () => {
@@ -169,6 +175,99 @@ describe('Endpoint.PatchUserMembersEndpoint', () => {
169
175
  // Check parent is still there
170
176
  expect(member.details.parents.length).toBe(1);
171
177
  expect(member.details.parents[0]).toEqual(existingMember.details.parents[0]);
178
+
179
+ // Check access
180
+ expect(member.users.filter(u => u.id === user.id)).toHaveLength(1);
181
+ });
182
+
183
+ test('Putting a new member without email address gives you access and links your user', async () => {
184
+ const organization = await new OrganizationFactory({ }).create();
185
+ const user = await new UserFactory({ organization }).create();
186
+
187
+ const token = await Token.createToken(user);
188
+
189
+ const arr: Body = new PatchableArray();
190
+ const put = MemberWithRegistrationsBlob.create({
191
+ details: MemberDetails.create({
192
+ firstName: 'Invented ' + Math.floor(Math.random() * 100000),
193
+ lastName: 'Last ' + Math.floor(Math.random() * 100000),
194
+ birthDay: new Date(),
195
+ // Security code is not required: you have access
196
+ }),
197
+ });
198
+ arr.addPut(put);
199
+
200
+ const request = Request.buildJson('PATCH', baseUrl, organization.getApiHost(), arr);
201
+ request.headers.authorization = 'Bearer ' + token.accessToken;
202
+ const response = await testServer.test(endpoint, request);
203
+ expect(response.status).toBe(200);
204
+
205
+ // Check id of the returned memebr matches the existing member
206
+ expect(response.body.members.length).toBe(1);
207
+
208
+ // Check data matches the original data + changes from the put
209
+ const member = response.body.members[0];
210
+
211
+ // Check access
212
+ expect(member.users.map(u => u.id)).toEqual([user.id]);
213
+ });
214
+
215
+ test('[Regression] Putting a new member with same name of existing member you have access to does not create a duplicate link', async () => {
216
+ const organization = await new OrganizationFactory({ }).create();
217
+ const user = await new UserFactory({ organization }).create();
218
+ const details = MemberDetails.create({
219
+ firstName,
220
+ lastName,
221
+ securityCode: 'ABC-123',
222
+ parents: [
223
+ Parent.create({
224
+ firstName: 'Jane',
225
+ lastName: 'Doe',
226
+ }),
227
+ ],
228
+ });
229
+
230
+ const existingMember = await new MemberFactory({
231
+ birthDay,
232
+ details,
233
+ }).create();
234
+
235
+ await MemberUserSyncer.linkUser(user.email, existingMember, true);
236
+
237
+ // Check access
238
+ expect(existingMember.users).toHaveLength(1);
239
+
240
+ const token = await Token.createToken(user);
241
+
242
+ const arr: Body = new PatchableArray();
243
+ const newBirthDay = new Date(existingMember.details.birthDay!.getTime() + 1000);
244
+ const put = MemberWithRegistrationsBlob.create({
245
+ details: MemberDetails.create({
246
+ firstName,
247
+ lastName,
248
+ birthDay: newBirthDay,
249
+ // Security code is not required: you have access
250
+ }),
251
+ });
252
+ arr.addPut(put);
253
+
254
+ const request = Request.buildJson('PATCH', baseUrl, organization.getApiHost(), arr);
255
+ request.headers.authorization = 'Bearer ' + token.accessToken;
256
+ const response = await testServer.test(endpoint, request);
257
+ expect(response.status).toBe(200);
258
+
259
+ // Check id of the returned memebr matches the existing member
260
+ expect(response.body.members.length).toBe(1);
261
+ expect(response.body.members[0].id).toBe(existingMember.id);
262
+
263
+ // Check data matches the original data + changes from the put
264
+ const member = response.body.members[0];
265
+ expect(member.details.firstName).toBe(firstName);
266
+ expect(member.details.lastName).toBe(lastName);
267
+ expect(member.details.birthDay).toEqual(newBirthDay);
268
+
269
+ // Check access
270
+ expect(member.users.map(u => u.id)).toEqual([user.id]);
172
271
  });
173
272
  });
174
273
 
@@ -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) {
@@ -156,30 +171,25 @@ export class PatchUserMembersEndpoint extends Endpoint<Params, Query, Body, Resp
156
171
  await Document.updateForMember(member);
157
172
  }
158
173
 
159
- // Modify members
160
- if (addedMembers.length > 0) {
161
- // Give access to created members
162
- await Member.users.reverse('members').link(user, addedMembers);
163
- }
164
-
165
174
  await PatchOrganizationMembersEndpoint.deleteMembers(request.body.getDeletes());
166
175
 
167
- members = await Member.getMembersWithRegistrationForUser(user);
168
-
169
- for (const member of addedMembers) {
170
- const updatedMember = members.find(m => m.id === member.id);
171
- if (updatedMember) {
172
- // Make sure we also give access to other parents
173
- await MemberUserSyncer.onChangeMember(updatedMember);
176
+ for (const updatedMember of addedMembers) {
177
+ await Member.users.load(updatedMember);
178
+ if (!Member.users.isLoaded(updatedMember)) {
179
+ throw new Error('Failed to load users for member ' + updatedMember.id);
180
+ }
174
181
 
175
- if (!updatedMember.users.find(u => u.id === user.id)) {
176
- // Also link the user to the member if the email address is missing in the details
177
- await MemberUserSyncer.linkUser(user.email, updatedMember, true);
178
- }
182
+ // Make sure we also give access to other parents
183
+ await MemberUserSyncer.onChangeMember(updatedMember);
179
184
 
180
- await Document.updateForMember(updatedMember);
185
+ if (!updatedMember.users.find(u => u.id === user.id)) {
186
+ // Also link the user to the member if the email address is missing in the details
187
+ await MemberUserSyncer.linkUser(user.email, updatedMember, true);
181
188
  }
189
+
190
+ await Document.updateForMember(updatedMember);
182
191
  }
192
+ members = await Member.getMembersWithRegistrationForUser(user);
183
193
 
184
194
  return new Response(
185
195
  await AuthenticatedStructures.membersBlob(members),
@@ -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;