@stamhoofd/backend 2.128.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stamhoofd/backend",
3
- "version": "2.128.0",
3
+ "version": "2.129.1",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "exports": {
@@ -57,20 +57,20 @@
57
57
  "@simonbackx/simple-endpoints": "1.21.1",
58
58
  "@simonbackx/simple-errors": "1.5.0",
59
59
  "@simonbackx/simple-logging": "1.0.1",
60
- "@stamhoofd/backend-env": "2.128.0",
61
- "@stamhoofd/backend-i18n": "2.128.0",
62
- "@stamhoofd/backend-middleware": "2.128.0",
63
- "@stamhoofd/crons": "2.128.0",
64
- "@stamhoofd/email": "2.128.0",
65
- "@stamhoofd/excel-writer": "2.128.0",
66
- "@stamhoofd/logging": "2.128.0",
67
- "@stamhoofd/models": "2.128.0",
68
- "@stamhoofd/object-differ": "2.128.0",
69
- "@stamhoofd/queues": "2.128.0",
70
- "@stamhoofd/sql": "2.128.0",
71
- "@stamhoofd/structures": "2.128.0",
72
- "@stamhoofd/types": "2.128.0",
73
- "@stamhoofd/utility": "2.128.0",
60
+ "@stamhoofd/backend-env": "2.129.1",
61
+ "@stamhoofd/backend-i18n": "2.129.1",
62
+ "@stamhoofd/backend-middleware": "2.129.1",
63
+ "@stamhoofd/crons": "2.129.1",
64
+ "@stamhoofd/email": "2.129.1",
65
+ "@stamhoofd/excel-writer": "2.129.1",
66
+ "@stamhoofd/logging": "2.129.1",
67
+ "@stamhoofd/models": "2.129.1",
68
+ "@stamhoofd/object-differ": "2.129.1",
69
+ "@stamhoofd/queues": "2.129.1",
70
+ "@stamhoofd/sql": "2.129.1",
71
+ "@stamhoofd/structures": "2.129.1",
72
+ "@stamhoofd/types": "2.129.1",
73
+ "@stamhoofd/utility": "2.129.1",
74
74
  "archiver": "7.0.1",
75
75
  "axios": "1.16.0",
76
76
  "base-x": "3.0.11",
@@ -91,7 +91,7 @@
91
91
  "stripe": "16.12.0"
92
92
  },
93
93
  "devDependencies": {
94
- "@stamhoofd/test-utils": "2.128.0",
94
+ "@stamhoofd/test-utils": "2.129.1",
95
95
  "@types/cookie": "0.6.0",
96
96
  "@types/luxon": "3.7.1",
97
97
  "@types/mailparser": "3.4.6",
@@ -107,5 +107,5 @@
107
107
  "publishConfig": {
108
108
  "access": "public"
109
109
  },
110
- "gitHead": "77c7e13c592a4d51542716d204c3b39dd9d28ec7"
110
+ "gitHead": "fed4dfbbbb768c9d57c476b93a3a42dfac6dd69b"
111
111
  }
@@ -16,7 +16,7 @@ export const MOLLIE_REFUNDS_MINIMUM_DATE = new Date('2026-06-22T00:00:00.000Z');
16
16
  let lastRun: Date | null = null;
17
17
  export async function checkMollieRefunds() {
18
18
  if (STAMHOOFD.environment !== 'development') {
19
- if (lastRun && new Date().getTime() - lastRun.getTime() < 1000 * 60 * 60 * 24) {
19
+ if (lastRun && new Date().getTime() - lastRun.getTime() < 1000 * 60 * 60 * 12) {
20
20
  return;
21
21
  }
22
22
  lastRun = new Date();
@@ -47,6 +47,7 @@ describe('Endpoint.PatchOrganizationMembersEndpoint', () => {
47
47
  lastName,
48
48
  birthDay,
49
49
  generateData: true,
50
+ generateSensitiveData: true,
50
51
  }).create();
51
52
 
52
53
  const token = await Token.createToken(user);
@@ -94,6 +95,7 @@ describe('Endpoint.PatchOrganizationMembersEndpoint', () => {
94
95
  lastName,
95
96
  birthDay,
96
97
  generateData: true,
98
+ generateSensitiveData: true,
97
99
  // should be member of the same organization
98
100
  organization,
99
101
  }).create();
@@ -148,6 +150,7 @@ describe('Endpoint.PatchOrganizationMembersEndpoint', () => {
148
150
  lastName,
149
151
  birthDay,
150
152
  generateData: true,
153
+ generateSensitiveData: true,
151
154
  // should be member of the same organization
152
155
  organization,
153
156
  }).create();
@@ -157,6 +160,7 @@ describe('Endpoint.PatchOrganizationMembersEndpoint', () => {
157
160
  lastName,
158
161
  birthDay,
159
162
  generateData: true,
163
+ generateSensitiveData: true,
160
164
  // should be member of the same organization
161
165
  organization,
162
166
  }).create();
@@ -185,7 +189,7 @@ describe('Endpoint.PatchOrganizationMembersEndpoint', () => {
185
189
  request.headers.authorization = 'Bearer ' + token.accessToken;
186
190
  await expect(testServer.test(endpoint, request))
187
191
  .rejects
188
- .toThrow(STExpect.errorWithCode('not_found'));
192
+ .toThrow(STExpect.errorWithCode('known_member_missing_rights'));
189
193
  });
190
194
 
191
195
  test('Should be able to create duplicate from another organization in userMode organization', async () => {
@@ -215,6 +219,7 @@ describe('Endpoint.PatchOrganizationMembersEndpoint', () => {
215
219
  lastName,
216
220
  birthDay,
217
221
  generateData: true,
222
+ generateSensitiveData: true,
218
223
  // member of other organization
219
224
  organization: otherOrganization,
220
225
  details: MemberDetails.create({
@@ -33,13 +33,38 @@ type ResponseBody = MembersBlob;
33
33
  export const securityCodeLimiter = new RateLimiter({
34
34
  limits: [
35
35
  {
36
- // Max 10 a day
37
- limit: 10,
36
+ // Max 5 per 15 minutes (this makes sure accidental enters block maximum 15 minutes and not the whole day)
37
+ limit: 5,
38
+ duration: 15 * 60 * 1000 * 60,
39
+ },
40
+ {
41
+ // Max 12 a day
42
+ limit: 12,
38
43
  duration: 24 * 60 * 1000 * 60,
39
44
  },
40
45
  ],
41
46
  });
42
47
 
48
+ export const duplicateCheckLimiter = new RateLimiter({
49
+ limits: [
50
+ {
51
+ // Max 5 requests per 5 minutes
52
+ limit: 5,
53
+ duration: 15 * 60 * 1000,
54
+ },
55
+ {
56
+ // Max 6 requests per 10 minutes
57
+ limit: 6,
58
+ duration: 15 * 60 * 1000,
59
+ },
60
+ {
61
+ // Max 7 requests per day
62
+ limit: 7,
63
+ duration: 24 * 60 * 60 * 1000,
64
+ },
65
+ ],
66
+ });
67
+
43
68
  /**
44
69
  * One endpoint to create, patch and delete members and their registrations and payments
45
70
  */
@@ -168,7 +193,7 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
168
193
  throw Context.auth.memberNotFoundOrNoAccess();
169
194
  }
170
195
 
171
- await PatchOrganizationMembersEndpoint.checkCanAccessMember(member, securityCode, 'patch');
196
+ await PatchOrganizationMembersEndpoint.checkCanAccessMember(member, securityCode, 'direct');
172
197
 
173
198
  patch = await Context.auth.filterMemberPatch(member, patch);
174
199
  const originalDetails = member.details.clone();
@@ -1011,13 +1036,13 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
1011
1036
  await log.save();
1012
1037
  }
1013
1038
 
1014
- static async checkCanAccessMember(member: MemberWithUsersRegistrationsAndGroups, securityCode: string | null | undefined, type: 'put' | 'patch') {
1015
- if ((type === 'put' && await member.isSafeToMergeDuplicateWithoutSecurityCode(Context.auth.user.email)) || await Context.auth.canAccessMember(member, PermissionLevel.Write)) {
1039
+ static async checkCanAccessMember(member: MemberWithUsersRegistrationsAndGroups, securityCode: string | null | undefined, type: 'put' | 'patch' | 'direct') {
1040
+ if ((type !== 'direct' && await member.isSafeToMergeDuplicateWithoutSecurityCode(Context.auth.user.email)) || await Context.auth.canAccessMember(member, PermissionLevel.Write)) {
1016
1041
  console.log('checkSecurityCode: without security code: allowed for ' + member.id);
1017
1042
  } else if (securityCode) {
1018
1043
  await this.checkSecurityCode(member, securityCode);
1019
1044
  } else {
1020
- if (type === 'patch') {
1045
+ if (type === 'direct') {
1021
1046
  throw Context.auth.memberNotFoundOrNoAccess();
1022
1047
  }
1023
1048
 
@@ -1029,6 +1054,9 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
1029
1054
  email: Context.auth.user.email,
1030
1055
  }),
1031
1056
  statusCode: 400,
1057
+ meta: {
1058
+ id: member.id,
1059
+ },
1032
1060
  });
1033
1061
  }
1034
1062
  }
@@ -1052,6 +1080,24 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
1052
1080
  return;
1053
1081
  }
1054
1082
 
1083
+ if ((!member.organizationId && !Context.auth.hasSomePlatformAccess()) || (member.organizationId && !await Context.auth.hasSomeAccess(member.organizationId))) {
1084
+ try {
1085
+ duplicateCheckLimiter.track(member.organizationId + '-' + Formatter.slug(member.details.name), 1);
1086
+ } catch (e) {
1087
+ Email.sendWebmaster({
1088
+ subject: '[Limiet] Limiet bereikt voor aantal duplicate checks per member name',
1089
+ text: $t(`%EA`) + ' ' + member.details.name + ' ' + '(Org: ' + ' ' + member.organizationId + ')' + ' by ' + Context.user?.id + '\n\n' + e.message + '\n\nStamhoofd',
1090
+ });
1091
+
1092
+ throw new SimpleError({
1093
+ code: 'too_many_tries',
1094
+ message: 'Too many names',
1095
+ human: $t(`%ZbQ`),
1096
+ field: 'details.name',
1097
+ });
1098
+ }
1099
+ }
1100
+
1055
1101
  // Check for duplicates and prevent creating a duplicate member by a user
1056
1102
  const duplicate = await this.findExistingMember(member);
1057
1103
  if (duplicate) {
@@ -1,14 +1,14 @@
1
1
  import { Request } from '@simonbackx/simple-endpoints';
2
2
  import { EmailMocker } from '@stamhoofd/email';
3
3
  import type { RateLimiter } from '@stamhoofd/models';
4
- import { EmailTemplateFactory, Member, MemberFactory, OrganizationFactory, Token, UserFactory } from '@stamhoofd/models';
5
- import { EmailTemplateType, MemberDetails, Parent, ParentType, SecurityCodeSendMethod, SendMemberSecurityCodeRequest } from '@stamhoofd/structures';
4
+ import { AuditLog, EmailTemplateFactory, Member, MemberFactory, OrganizationFactory, Token, UserFactory } from '@stamhoofd/models';
5
+ import { AuditLogReplacementType, AuditLogType, EmailTemplateType, MemberDetails, Parent, ParentType, SecurityCodeSendMethod, SendMemberSecurityCodeRequest } from '@stamhoofd/structures';
6
6
  import { STExpect, TestUtils } from '@stamhoofd/test-utils';
7
7
  import { Formatter } from '@stamhoofd/utility';
8
8
  import type { SMSMocker } from '../../../../tests/helpers/SMSMocker.js';
9
9
  import { testServer } from '../../../../tests/helpers/TestServer.js';
10
10
  import { initSMSApi } from '../../../../tests/init/index.js';
11
- import { memberPhoneLookupLimiter, memberSecurityCodeSendLimiter, nameSecurityCodeSendLimiter, SendMemberSecurityCodeEndpoint, smsOrganizationLimiter, userSecurityCodeSendLimiter } from './SendMemberSecurityCodeEndpoint.js';
11
+ import { memberPhoneLookupLimiter, memberSecurityCodeSendLimiter, SendMemberSecurityCodeEndpoint, smsOrganizationLimiter, userSecurityCodeSendLimiter } from './SendMemberSecurityCodeEndpoint.js';
12
12
 
13
13
  const baseUrl = `/members/security-code`;
14
14
  const endpoint = new SendMemberSecurityCodeEndpoint();
@@ -31,7 +31,6 @@ describe('Endpoint.SendMemberSecurityCode', () => {
31
31
 
32
32
  // Rate limiters are in-memory singletons, reset them between tests
33
33
  resetLimiter(memberSecurityCodeSendLimiter);
34
- resetLimiter(nameSecurityCodeSendLimiter);
35
34
  resetLimiter(userSecurityCodeSendLimiter);
36
35
  resetLimiter(smsOrganizationLimiter);
37
36
  resetLimiter(memberPhoneLookupLimiter);
@@ -75,12 +74,10 @@ describe('Endpoint.SendMemberSecurityCode', () => {
75
74
  }
76
75
 
77
76
  test('sends the code via email to all known email addresses', async () => {
78
- const { organization, token } = await setup();
77
+ const { organization, token, member } = await setup();
79
78
 
80
79
  const request = buildRequest(organization.getApiHost(), token, SendMemberSecurityCodeRequest.create({
81
- firstName: 'Jef',
82
- lastName: 'Testman',
83
- birthDay: new Date(Date.UTC(2010, 4, 5)),
80
+ memberId: member.id,
84
81
  method: SecurityCodeSendMethod.Email,
85
82
  }));
86
83
 
@@ -99,19 +96,17 @@ describe('Endpoint.SendMemberSecurityCode', () => {
99
96
 
100
97
  test('sends the code via SMS to the member phone number', async () => {
101
98
  const mocker: SMSMocker = initSMSApi();
102
- const { organization, token } = await setup();
99
+ const { organization, token, member } = await setup();
103
100
 
104
101
  const request = buildRequest(organization.getApiHost(), token, SendMemberSecurityCodeRequest.create({
105
- firstName: 'Jef',
106
- lastName: 'Testman',
107
- birthDay: new Date(Date.UTC(2010, 4, 5)),
102
+ memberId: member.id,
108
103
  method: SecurityCodeSendMethod.SMS,
109
104
  }));
110
105
 
111
106
  const response = await testServer.test(endpoint, request);
112
107
 
113
108
  expect(response.body.method).toBe(SecurityCodeSendMethod.SMS);
114
- expect(response.body.maskedRecipient).toBe('•••• 56');
109
+ expect(response.body.maskedRecipient).toBe('•• •• 56');
115
110
 
116
111
  expect(mocker.sentMessages.length).toBe(1);
117
112
  expect(mocker.lastMessage!.recipient).toEqual(32470123456);
@@ -123,12 +118,10 @@ describe('Endpoint.SendMemberSecurityCode', () => {
123
118
 
124
119
  test('cycles to the next phone number on retries', async () => {
125
120
  const mocker: SMSMocker = initSMSApi();
126
- const { organization, token } = await setup();
121
+ const { organization, token, member } = await setup();
127
122
 
128
123
  const body = SendMemberSecurityCodeRequest.create({
129
- firstName: 'Jef',
130
- lastName: 'Testman',
131
- birthDay: new Date(Date.UTC(2010, 4, 5)),
124
+ memberId: member.id,
132
125
  method: SecurityCodeSendMethod.SMS,
133
126
  tryCount: 0,
134
127
  });
@@ -140,9 +133,7 @@ describe('Endpoint.SendMemberSecurityCode', () => {
140
133
  resetLimiter(memberSecurityCodeSendLimiter);
141
134
 
142
135
  const retry = SendMemberSecurityCodeRequest.create({
143
- firstName: 'Jef',
144
- lastName: 'Testman',
145
- birthDay: new Date(Date.UTC(2010, 4, 5)),
136
+ memberId: member.id,
146
137
  method: SecurityCodeSendMethod.SMS,
147
138
  tryCount: 1,
148
139
  });
@@ -150,19 +141,17 @@ describe('Endpoint.SendMemberSecurityCode', () => {
150
141
 
151
142
  expect(mocker.sentMessages.length).toBe(2);
152
143
  expect(mocker.lastMessage!.recipient).toEqual(32471987654);
153
- expect(response.body.maskedRecipient).toBe('•••• 54');
144
+ expect(response.body.maskedRecipient).toBe('•• •• 54');
154
145
  });
155
146
 
156
147
  test('only sends to the provided phone number when it matches a known number', async () => {
157
148
  const mocker: SMSMocker = initSMSApi();
158
- const { organization, token } = await setup();
149
+ const { organization, token, member } = await setup();
159
150
 
160
151
  // Provide the parent number in national format. Even though tryCount 0 would normally select the
161
152
  // member number, an exact (normalized) match must win.
162
153
  const request = buildRequest(organization.getApiHost(), token, SendMemberSecurityCodeRequest.create({
163
- firstName: 'Jef',
164
- lastName: 'Testman',
165
- birthDay: new Date(Date.UTC(2010, 4, 5)),
154
+ memberId: member.id,
166
155
  method: SecurityCodeSendMethod.SMS,
167
156
  tryCount: 0,
168
157
  phone: '0471 98 76 54',
@@ -172,17 +161,15 @@ describe('Endpoint.SendMemberSecurityCode', () => {
172
161
 
173
162
  expect(mocker.sentMessages.length).toBe(1);
174
163
  expect(mocker.lastMessage!.recipient).toEqual(32471987654);
175
- expect(response.body.maskedRecipient).toBe('•••• 54');
164
+ expect(response.body.maskedRecipient).toBe('•• •• 54');
176
165
  });
177
166
 
178
167
  test('throws when the provided phone number is not known for the member', async () => {
179
168
  const mocker: SMSMocker = initSMSApi();
180
- const { organization, token } = await setup();
169
+ const { organization, token, member } = await setup();
181
170
 
182
171
  const request = buildRequest(organization.getApiHost(), token, SendMemberSecurityCodeRequest.create({
183
- firstName: 'Jef',
184
- lastName: 'Testman',
185
- birthDay: new Date(Date.UTC(2010, 4, 5)),
172
+ memberId: member.id,
186
173
  method: SecurityCodeSendMethod.SMS,
187
174
  phone: '+32 490 00 00 00',
188
175
  }));
@@ -205,9 +192,7 @@ describe('Endpoint.SendMemberSecurityCode', () => {
205
192
  }
206
193
 
207
194
  const request = buildRequest(organization.getApiHost(), token, SendMemberSecurityCodeRequest.create({
208
- firstName: 'Jef',
209
- lastName: 'Testman',
210
- birthDay: new Date(Date.UTC(2010, 4, 5)),
195
+ memberId: member.id,
211
196
  method: SecurityCodeSendMethod.SMS,
212
197
  phone: '+32 490 00 00 00',
213
198
  }));
@@ -218,17 +203,48 @@ describe('Endpoint.SendMemberSecurityCode', () => {
218
203
  expect(mocker.sentMessages.length).toBe(0);
219
204
  });
220
205
 
221
- test('can look up the member by id', async () => {
222
- const { organization, token, member } = await setup();
206
+ test('creates an audit log when a security code is requested via email', async () => {
207
+ const { organization, user, token, member } = await setup();
223
208
 
224
- const request = buildRequest(organization.getApiHost(), token, SendMemberSecurityCodeRequest.create({
209
+ await testServer.test(endpoint, buildRequest(organization.getApiHost(), token, SendMemberSecurityCodeRequest.create({
225
210
  memberId: member.id,
226
211
  method: SecurityCodeSendMethod.Email,
227
- }));
212
+ })));
213
+
214
+ const log = await AuditLog.select()
215
+ .where('type', AuditLogType.MemberSecurityCodeRequested)
216
+ .where('objectId', member.id)
217
+ .first(true);
218
+
219
+ expect(log.userId).toBe(user.id);
220
+ expect(log.organizationId).toBe(member.organizationId);
221
+ expect(log.replacements.get('m')).toMatchObject({
222
+ id: member.id,
223
+ value: member.details.name,
224
+ type: AuditLogReplacementType.Member,
225
+ });
226
+ expect(log.replacements.get('method')?.value).toBe('e-mail');
227
+ expect(log.replacements.get('recipient')?.value).toContain('kid@example.com');
228
+ expect(log.replacements.get('recipient')?.value).toContain('parent@example.com');
229
+ });
228
230
 
229
- const response = await testServer.test(endpoint, request);
230
- expect(response.body.method).toBe(SecurityCodeSendMethod.Email);
231
- expect(await EmailMocker.transactional.getSucceededCount()).toBe(2);
231
+ test('creates an audit log with the masked recipient when a security code is requested via SMS', async () => {
232
+ initSMSApi();
233
+ const { user, organization, token, member } = await setup();
234
+
235
+ await testServer.test(endpoint, buildRequest(organization.getApiHost(), token, SendMemberSecurityCodeRequest.create({
236
+ memberId: member.id,
237
+ method: SecurityCodeSendMethod.SMS,
238
+ })));
239
+
240
+ const log = await AuditLog.select()
241
+ .where('type', AuditLogType.MemberSecurityCodeRequested)
242
+ .where('objectId', member.id)
243
+ .first(true);
244
+
245
+ expect(log.userId).toBe(user.id);
246
+ expect(log.replacements.get('method')?.value).toBe('SMS');
247
+ expect(log.replacements.get('recipient')?.value).toBe('•• •• 56');
232
248
  });
233
249
 
234
250
  test('generates a security code if the member has none', async () => {
@@ -239,9 +255,7 @@ describe('Endpoint.SendMemberSecurityCode', () => {
239
255
  await member.save();
240
256
 
241
257
  const request = buildRequest(organization.getApiHost(), token, SendMemberSecurityCodeRequest.create({
242
- firstName: 'Jef',
243
- lastName: 'Testman',
244
- birthDay: new Date(Date.UTC(2010, 4, 5)),
258
+ memberId: member.id,
245
259
  method: SecurityCodeSendMethod.Email,
246
260
  }));
247
261
 
@@ -261,9 +275,7 @@ describe('Endpoint.SendMemberSecurityCode', () => {
261
275
  const { organization, token } = await setup();
262
276
 
263
277
  const request = buildRequest(organization.getApiHost(), token, SendMemberSecurityCodeRequest.create({
264
- firstName: 'Unknown',
265
- lastName: 'Person',
266
- birthDay: new Date(Date.UTC(2000, 0, 1)),
278
+ memberId: 'not-found',
267
279
  method: SecurityCodeSendMethod.Email,
268
280
  }));
269
281
 
@@ -274,12 +286,10 @@ describe('Endpoint.SendMemberSecurityCode', () => {
274
286
 
275
287
  test('throws when the member has no phone number for SMS', async () => {
276
288
  initSMSApi();
277
- const { organization, token } = await setup({ phone: null, parents: [] });
289
+ const { organization, token, member } = await setup({ phone: null, parents: [] });
278
290
 
279
291
  const request = buildRequest(organization.getApiHost(), token, SendMemberSecurityCodeRequest.create({
280
- firstName: 'Jef',
281
- lastName: 'Testman',
282
- birthDay: new Date(Date.UTC(2010, 4, 5)),
292
+ memberId: member.id,
283
293
  method: SecurityCodeSendMethod.SMS,
284
294
  }));
285
295
 
@@ -288,13 +298,11 @@ describe('Endpoint.SendMemberSecurityCode', () => {
288
298
  .toThrow(STExpect.errorWithCode('no_phone'));
289
299
  });
290
300
 
291
- test('rate limits sending per member', async () => {
292
- const { organization, token } = await setup();
301
+ test('rate limits sending email per member', async () => {
302
+ const { organization, token, member } = await setup();
293
303
 
294
304
  const body = () => SendMemberSecurityCodeRequest.create({
295
- firstName: 'Jef',
296
- lastName: 'Testman',
297
- birthDay: new Date(Date.UTC(2010, 4, 5)),
305
+ memberId: member.id,
298
306
  method: SecurityCodeSendMethod.Email,
299
307
  });
300
308
 
@@ -308,12 +316,10 @@ describe('Endpoint.SendMemberSecurityCode', () => {
308
316
  test('rate limits sending SMS per member', async () => {
309
317
  const mocker: SMSMocker = initSMSApi();
310
318
 
311
- const { organization, token } = await setup();
319
+ const { organization, token, member } = await setup();
312
320
 
313
321
  const body = () => SendMemberSecurityCodeRequest.create({
314
- firstName: 'Jef',
315
- lastName: 'Testman',
316
- birthDay: new Date(Date.UTC(2010, 4, 5)),
322
+ memberId: member.id,
317
323
  method: SecurityCodeSendMethod.SMS,
318
324
  });
319
325
 
@@ -327,28 +333,6 @@ describe('Endpoint.SendMemberSecurityCode', () => {
327
333
  expect(mocker.sentMessages.length).toBe(3);
328
334
  });
329
335
 
330
- test('rate limits by member name and organization to prevent birthday guessing', async () => {
331
- const { organization, token } = await setup();
332
-
333
- // Simulate that the hourly limit for this member name in this organization is already reached
334
- const nameKey = organization.id + ':jef testman';
335
- for (let i = 0; i < 10; i++) {
336
- nameSecurityCodeSendLimiter.track(nameKey, 1);
337
- }
338
-
339
- // Even with a wrong birth day, the request is blocked before the member lookup happens
340
- const request = buildRequest(organization.getApiHost(), token, SendMemberSecurityCodeRequest.create({
341
- firstName: 'Jef',
342
- lastName: 'Testman',
343
- birthDay: new Date(Date.UTC(1990, 0, 1)),
344
- method: SecurityCodeSendMethod.Email,
345
- }));
346
-
347
- await expect(testServer.test(endpoint, request))
348
- .rejects
349
- .toThrow(STExpect.errorWithCode('too_many_requests'));
350
- });
351
-
352
336
  test('rate limits SMS per organization to 25 per day', async () => {
353
337
  const mocker: SMSMocker = initSMSApi();
354
338
  const { organization, token, member } = await setup();
@@ -359,9 +343,7 @@ describe('Endpoint.SendMemberSecurityCode', () => {
359
343
  }
360
344
 
361
345
  const request = buildRequest(organization.getApiHost(), token, SendMemberSecurityCodeRequest.create({
362
- firstName: 'Jef',
363
- lastName: 'Testman',
364
- birthDay: new Date(Date.UTC(2010, 4, 5)),
346
+ memberId: member.id,
365
347
  method: SecurityCodeSendMethod.SMS,
366
348
  }));
367
349
 
@@ -372,7 +354,7 @@ describe('Endpoint.SendMemberSecurityCode', () => {
372
354
  });
373
355
 
374
356
  test('rate limits per user to prevent enumeration', async () => {
375
- const { organization, user, token } = await setup();
357
+ const { organization, user, token, member } = await setup();
376
358
 
377
359
  // Simulate that the user already reached the hourly limit
378
360
  for (let i = 0; i < 20; i++) {
@@ -380,9 +362,7 @@ describe('Endpoint.SendMemberSecurityCode', () => {
380
362
  }
381
363
 
382
364
  const request = buildRequest(organization.getApiHost(), token, SendMemberSecurityCodeRequest.create({
383
- firstName: 'Jef',
384
- lastName: 'Testman',
385
- birthDay: new Date(Date.UTC(2010, 4, 5)),
365
+ memberId: member.id,
386
366
  method: SecurityCodeSendMethod.Email,
387
367
  }));
388
368
 
@@ -394,12 +374,10 @@ describe('Endpoint.SendMemberSecurityCode', () => {
394
374
  test('reports an error when the SMS gateway fails', async () => {
395
375
  const mocker: SMSMocker = initSMSApi();
396
376
  mocker.forceFailure();
397
- const { organization, token } = await setup();
377
+ const { organization, token, member } = await setup();
398
378
 
399
379
  const request = buildRequest(organization.getApiHost(), token, SendMemberSecurityCodeRequest.create({
400
- firstName: 'Jef',
401
- lastName: 'Testman',
402
- birthDay: new Date(Date.UTC(2010, 4, 5)),
380
+ memberId: member.id,
403
381
  method: SecurityCodeSendMethod.SMS,
404
382
  }));
405
383
 
@@ -3,8 +3,8 @@ import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
3
3
  import { Endpoint, Response } from '@simonbackx/simple-endpoints';
4
4
  import { SimpleError } from '@simonbackx/simple-errors';
5
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';
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
8
  import type { Country } from '@stamhoofd/types/Country';
9
9
  import { Formatter } from '@stamhoofd/utility';
10
10
 
@@ -56,25 +56,6 @@ export const memberEmailSecurityCodeSendLimiter = new RateLimiter({
56
56
  ],
57
57
  });
58
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
59
  /**
79
60
  * Limit per user to avoid member enumeration attacks.
80
61
  */
@@ -190,51 +171,7 @@ export class SendMemberSecurityCodeEndpoint extends Endpoint<Params, Query, Body
190
171
  * String matching relies on the case- and accent-insensitive collation of the database.
191
172
  */
192
173
  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];
174
+ return (await Member.getByID(body.memberId)) ?? undefined;
238
175
  }
239
176
 
240
177
  /**
@@ -292,6 +229,8 @@ export class SendMemberSecurityCodeEndpoint extends Endpoint<Params, Query, Body
292
229
  type: 'transactional',
293
230
  });
294
231
 
232
+ await this.logSecurityCodeRequested(member, SecurityCodeSendMethod.Email, emails.join(', '));
233
+
295
234
  return SendMemberSecurityCodeResponse.create({
296
235
  method: SecurityCodeSendMethod.Email,
297
236
  maskedRecipient: '',
@@ -417,12 +356,43 @@ export class SendMemberSecurityCodeEndpoint extends Endpoint<Params, Query, Body
417
356
  defaultCountry: member.details.address?.country ?? organization?.address?.country,
418
357
  });
419
358
 
359
+ const maskedRecipient = this.maskPhone(phone);
360
+ await this.logSecurityCodeRequested(member, SecurityCodeSendMethod.SMS, maskedRecipient);
361
+
420
362
  return SendMemberSecurityCodeResponse.create({
421
363
  method: SecurityCodeSendMethod.SMS,
422
- maskedRecipient: this.maskPhone(phone),
364
+ maskedRecipient,
423
365
  });
424
366
  }
425
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
+
426
396
  private trackMemberLimit(member: Member, method: SecurityCodeSendMethod) {
427
397
  try {
428
398
  if (method === SecurityCodeSendMethod.Email) {
@@ -457,6 +427,6 @@ export class SendMemberSecurityCodeEndpoint extends Endpoint<Params, Query, Body
457
427
  private maskPhone(phone: string): string {
458
428
  const digits = phone.replace(/\D/g, '');
459
429
  const last = digits.substring(Math.max(0, digits.length - 2));
460
- return '•••• ' + last;
430
+ return '•• •• ' + last;
461
431
  }
462
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();
@@ -101,6 +102,9 @@ describe('Endpoint.PatchUserMembersEndpoint', () => {
101
102
  expect(member.details.birthDay).toEqual(newBirthDay);
102
103
  expect(member.details.email).toBe('anewemail@example.com'); // this has been merged
103
104
  expect(member.details.alternativeEmails).toHaveLength(0);
105
+
106
+ // Check access
107
+ expect(member.users.filter(u => u.id === user.id)).toHaveLength(1);
104
108
  });
105
109
 
106
110
  test('A duplicate member with existing registrations returns those registrations after a merge', async () => {
@@ -171,6 +175,99 @@ describe('Endpoint.PatchUserMembersEndpoint', () => {
171
175
  // Check parent is still there
172
176
  expect(member.details.parents.length).toBe(1);
173
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]);
174
271
  });
175
272
  });
176
273
 
@@ -171,30 +171,25 @@ export class PatchUserMembersEndpoint extends Endpoint<Params, Query, Body, Resp
171
171
  await Document.updateForMember(member);
172
172
  }
173
173
 
174
- // Modify members
175
- if (addedMembers.length > 0) {
176
- // Give access to created members
177
- await Member.users.reverse('members').link(user, addedMembers);
178
- }
179
-
180
174
  await PatchOrganizationMembersEndpoint.deleteMembers(request.body.getDeletes());
181
175
 
182
- members = await Member.getMembersWithRegistrationForUser(user);
183
-
184
- for (const member of addedMembers) {
185
- const updatedMember = members.find(m => m.id === member.id);
186
- if (updatedMember) {
187
- // Make sure we also give access to other parents
188
- 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
+ }
189
181
 
190
- if (!updatedMember.users.find(u => u.id === user.id)) {
191
- // Also link the user to the member if the email address is missing in the details
192
- await MemberUserSyncer.linkUser(user.email, updatedMember, true);
193
- }
182
+ // Make sure we also give access to other parents
183
+ await MemberUserSyncer.onChangeMember(updatedMember);
194
184
 
195
- 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);
196
188
  }
189
+
190
+ await Document.updateForMember(updatedMember);
197
191
  }
192
+ members = await Member.getMembersWithRegistrationForUser(user);
198
193
 
199
194
  return new Response(
200
195
  await AuthenticatedStructures.membersBlob(members),
@@ -4,6 +4,7 @@ import {
4
4
  Member,
5
5
  MemberPlatformMembership,
6
6
  MemberResponsibilityRecord,
7
+ MemberUser,
7
8
  MergedMember,
8
9
  Registration,
9
10
  User,
@@ -70,6 +71,7 @@ export async function mergeTwoMembers(base: Member, other: Member, options?: { u
70
71
  if (other.existsInDatabase) {
71
72
  await mergeRegistrations(base, other);
72
73
  await mergeUsers(base, other);
74
+ // await mergeUserMembers(base, other);
73
75
  await mergeResponsibilities(base, other);
74
76
  await mergeBalanceItems(base, other);
75
77
  await mergeDocuments(base, other);
@@ -114,6 +116,20 @@ async function mergeUsers(base: Member, other: Member) {
114
116
  await mergeModels(base, other, User);
115
117
  }
116
118
 
119
+ async function mergeUserMembers(base: Member, other: Member) {
120
+ const baseId = base.id;
121
+ const otherModels = await MemberUser.select().where('membersId', other.id).fetch();
122
+
123
+ for (const otherModel of otherModels) {
124
+ otherModel.membersId = baseId;
125
+
126
+ await otherModel.save({
127
+ skipMarkSaved: true,
128
+ skipSendEvents: true,
129
+ });
130
+ }
131
+ }
132
+
117
133
  async function mergeResponsibilities(base: Member, other: Member) {
118
134
  async function getResponsibilities(memberId: string) {
119
135
  const rows = await SQL.select()
@@ -1,5 +1,6 @@
1
1
  import { Database } from '@simonbackx/simple-database';
2
- import { Member, MemberFactory, MemberResponsibilityRecordFactory, User, UserFactory } from '@stamhoofd/models';
2
+ import { Member, MemberFactory, MemberResponsibilityRecordFactory, MemberUser, User, UserFactory } from '@stamhoofd/models';
3
+ import { SQL } from '@stamhoofd/sql';
3
4
  import { BooleanStatus, MemberDetails, Parent, UserPermissions } from '@stamhoofd/structures';
4
5
  import { TestUtils } from '@stamhoofd/test-utils';
5
6
  import { MemberUserSyncer } from './MemberUserSyncer.js';
@@ -1027,6 +1028,68 @@ describe('Helpers.MemberUserSyncer', () => {
1027
1028
  });
1028
1029
  });
1029
1030
 
1031
+ describe('removeDuplicates', () => {
1032
+ const link = async (memberId: string, userId: string) => {
1033
+ await SQL.insert(MemberUser.table)
1034
+ .columns('membersId', 'usersId')
1035
+ .values([memberId, userId])
1036
+ .insert();
1037
+ };
1038
+
1039
+ const countLinks = async (memberId: string, userId: string) => {
1040
+ const rows = await SQL.select()
1041
+ .from(SQL.table(MemberUser.table))
1042
+ .where(SQL.column('membersId'), memberId)
1043
+ .where(SQL.column('usersId'), userId)
1044
+ .fetch();
1045
+ return rows.length;
1046
+ };
1047
+
1048
+ test('Duplicate links for a single member are collapsed to one', async () => {
1049
+ const member = await new MemberFactory({}).create();
1050
+ const otherMember = await new MemberFactory({}).create();
1051
+ const userA = await new UserFactory({ email: 'a@example.com' }).create();
1052
+ const userB = await new UserFactory({ email: 'b@example.com' }).create();
1053
+
1054
+ // 3 duplicate links for (member, userA)
1055
+ await link(member.id, userA.id);
1056
+ await link(member.id, userA.id);
1057
+ await link(member.id, userA.id);
1058
+
1059
+ // A single link for (member, userB) should be kept untouched
1060
+ await link(member.id, userB.id);
1061
+
1062
+ // Duplicates for another member should not be affected when scoping to member
1063
+ await link(otherMember.id, userA.id);
1064
+ await link(otherMember.id, userA.id);
1065
+
1066
+ await MemberUserSyncer.removeDuplicates(member.id);
1067
+
1068
+ expect(await countLinks(member.id, userA.id)).toBe(1);
1069
+ expect(await countLinks(member.id, userB.id)).toBe(1);
1070
+
1071
+ // Untouched: scoped to member only
1072
+ expect(await countLinks(otherMember.id, userA.id)).toBe(2);
1073
+ });
1074
+
1075
+ test('Duplicate links across all members are collapsed when memberId is null', async () => {
1076
+ const member1 = await new MemberFactory({}).create();
1077
+ const member2 = await new MemberFactory({}).create();
1078
+ const userA = await new UserFactory({ email: 'a@example.com' }).create();
1079
+
1080
+ await link(member1.id, userA.id);
1081
+ await link(member1.id, userA.id);
1082
+ await link(member2.id, userA.id);
1083
+ await link(member2.id, userA.id);
1084
+ await link(member2.id, userA.id);
1085
+
1086
+ await MemberUserSyncer.removeDuplicates(null);
1087
+
1088
+ expect(await countLinks(member1.id, userA.id)).toBe(1);
1089
+ expect(await countLinks(member2.id, userA.id)).toBe(1);
1090
+ });
1091
+ });
1092
+
1030
1093
  describe('Members with the same email addresses', () => {
1031
1094
  test('The most recent member is linked to a user if both do not have responsibilities', async () => {
1032
1095
  const member1 = await new MemberFactory({
@@ -1,13 +1,55 @@
1
1
  import type { MemberWithUsers } from '@stamhoofd/models';
2
- import { CachedBalance, Member, MemberResponsibilityRecord, Organization, Platform, User } from '@stamhoofd/models';
2
+ import { CachedBalance, Member, MemberResponsibilityRecord, MemberUser, Organization, Platform, User } from '@stamhoofd/models';
3
3
  import { QueueHandler } from '@stamhoofd/queues';
4
- import { SQL } from '@stamhoofd/sql';
4
+ import { SQL, SQLAlias, SQLCount, SQLSelectAs } 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
8
  import { AuditLogService } from '../services/AuditLogService.js';
9
9
 
10
10
  export class MemberUserSyncerStatic {
11
+ /**
12
+ * Delete duplicate member <-> user links (the same user linked to the same member more than once),
13
+ * keeping the row with the lowest id for each (membersId, usersId) pair.
14
+ *
15
+ * Runs as a single atomic statement, either scoped to one member or across all members (memberId = null).
16
+ */
17
+ async removeDuplicates(memberId: string | null) {
18
+ // Find only the (membersId, usersId) pairs that actually have duplicates, together with the
19
+ // lowest id we want to keep. HAVING COUNT(*) > 1 keeps this derived table tiny (duplicates are
20
+ // rare), so we only materialize + join the handful of affected groups instead of the whole table.
21
+ // It has to be a derived table so MySQL materializes it up front: a plain self-join against the
22
+ // delete target does not reliably delete rows (and is forbidden as a subquery on the same table).
23
+ const duplicateGroups = SQL.select(
24
+ SQL.column('membersId'),
25
+ SQL.column('usersId'),
26
+ new SQLSelectAs(SQL.min(SQL.column('id')), new SQLAlias('keepId')),
27
+ )
28
+ .from(SQL.table(MemberUser.table))
29
+ .groupBy(SQL.column('membersId'), SQL.column('usersId'))
30
+ .having(SQL.where(new SQLCount(), '>', 1));
31
+
32
+ if (memberId !== null) {
33
+ duplicateGroups.where(SQL.column('membersId'), memberId);
34
+ }
35
+
36
+ // Delete the duplicate rows: every row in an affected group whose id is higher than the one we keep.
37
+ const query = SQL.delete()
38
+ .from(SQL.table(MemberUser.table, 'duplicate'))
39
+ .join(
40
+ SQL.join(duplicateGroups.as('duplicate_group'))
41
+ .where(SQL.column('duplicate_group', 'membersId'), SQL.column('duplicate', 'membersId'))
42
+ .where(SQL.column('duplicate_group', 'usersId'), SQL.column('duplicate', 'usersId'))
43
+ .where(SQL.column('duplicate', 'id'), '>', SQL.column('duplicate_group', 'keepId')),
44
+ );
45
+
46
+ if (memberId !== null) {
47
+ query.where(SQL.column('duplicate', 'membersId'), memberId);
48
+ }
49
+
50
+ await query.delete();
51
+ }
52
+
11
53
  /**
12
54
  * Call when:
13
55
  * - responsibilities have changed
@@ -23,6 +65,22 @@ export class MemberUserSyncerStatic {
23
65
  throw new Error('Failed to load users for member ' + member.id);
24
66
  }
25
67
 
68
+ // Check duplicate users
69
+ // Cleanup legacy bugs, but also cleans up merges
70
+ let removeDuplicates = false;
71
+ for (const user of member.users) {
72
+ const o = member.users.filter(u => u.id === user.id);
73
+ if (o.length > 1) {
74
+ removeDuplicates = true;
75
+ break;
76
+ }
77
+ }
78
+
79
+ if (removeDuplicates) {
80
+ await this.removeDuplicates(member.id);
81
+ await Member.users.load(member);
82
+ }
83
+
26
84
  const { userEmails, parentEmails, unverifiedEmails, allEmails } = this.getMemberAccessEmails(member.details);
27
85
 
28
86
  // Make sure all these users have access to the member
@@ -347,7 +347,7 @@ export class StripeInvoicer {
347
347
  break;
348
348
  }
349
349
  const nextMonth = new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1);
350
- const { end: endNext } = StripeInvoicer.getMonthUnixStartEnd(currentMonth);
350
+ const { end: endNext } = StripeInvoicer.getMonthUnixStartEnd(nextMonth);
351
351
  const isLastMonth = endNext >= stopAt.getTime() / 1000;
352
352
  await this.generateInvoices(sellingOrganization, currentMonth, { ...options, force: (isLastMonth && !!options?.forceLast) || !!options?.force });
353
353
  currentMonth = nextMonth;