@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.
- package/package.json +17 -17
- package/src/crons/mollie-refunds.ts +1 -1
- package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.test.ts +6 -1
- package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +56 -27
- package/src/endpoints/global/members/SendMemberSecurityCodeEndpoint.test.ts +388 -0
- package/src/endpoints/global/members/SendMemberSecurityCodeEndpoint.ts +432 -0
- package/src/endpoints/global/members/shouldCheckIfMemberIsDuplicate.ts +0 -1
- package/src/endpoints/global/registration/PatchUserMembersEndpoint.test.ts +99 -0
- package/src/endpoints/global/registration/PatchUserMembersEndpoint.ts +29 -19
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +3 -6
- package/src/helpers/MemberMerger.ts +16 -0
- package/src/helpers/MemberUserSyncer.test.ts +64 -1
- package/src/helpers/MemberUserSyncer.ts +61 -22
- package/src/helpers/StripeInvoicer.ts +1 -1
- package/src/migrations/1783097842-default-member-security-code-email-template.sql +3 -0
- package/src/{migrations/1761665607-sync-member-users.ts → seeds/1783100286-sync-member-users-security-codes.ts} +2 -7
- package/src/services/SMSService.test.ts +48 -0
- package/src/services/SMSService.ts +101 -0
- package/tests/helpers/SMSMocker.ts +61 -0
- package/tests/init/index.ts +1 -0
- package/tests/init/initSMSApi.ts +14 -0
|
@@ -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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
176
|
-
|
|
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
|
-
|
|
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
|
|
159
|
-
throw
|
|
160
|
-
|
|
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;
|