@stamhoofd/backend 2.128.0 → 2.129.2
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/crons/stripe-invoices.ts +1 -1
- package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.test.ts +6 -1
- package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +62 -7
- package/src/endpoints/global/members/SendMemberSecurityCodeEndpoint.test.ts +70 -92
- package/src/endpoints/global/members/SendMemberSecurityCodeEndpoint.ts +38 -68
- package/src/endpoints/global/members/shouldCheckIfMemberIsDuplicate.ts +0 -1
- package/src/endpoints/global/registration/PatchUserMembersEndpoint.test.ts +97 -0
- package/src/endpoints/global/registration/PatchUserMembersEndpoint.ts +13 -18
- package/src/helpers/MemberMerger.test.ts +134 -0
- package/src/helpers/MemberMerger.ts +22 -0
- package/src/helpers/MemberUserSyncer.test.ts +65 -2
- package/src/helpers/MemberUserSyncer.ts +62 -3
- package/src/helpers/StripeInvoicer.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stamhoofd/backend",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.129.2",
|
|
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.
|
|
61
|
-
"@stamhoofd/backend-i18n": "2.
|
|
62
|
-
"@stamhoofd/backend-middleware": "2.
|
|
63
|
-
"@stamhoofd/crons": "2.
|
|
64
|
-
"@stamhoofd/email": "2.
|
|
65
|
-
"@stamhoofd/excel-writer": "2.
|
|
66
|
-
"@stamhoofd/logging": "2.
|
|
67
|
-
"@stamhoofd/models": "2.
|
|
68
|
-
"@stamhoofd/object-differ": "2.
|
|
69
|
-
"@stamhoofd/queues": "2.
|
|
70
|
-
"@stamhoofd/sql": "2.
|
|
71
|
-
"@stamhoofd/structures": "2.
|
|
72
|
-
"@stamhoofd/types": "2.
|
|
73
|
-
"@stamhoofd/utility": "2.
|
|
60
|
+
"@stamhoofd/backend-env": "2.129.2",
|
|
61
|
+
"@stamhoofd/backend-i18n": "2.129.2",
|
|
62
|
+
"@stamhoofd/backend-middleware": "2.129.2",
|
|
63
|
+
"@stamhoofd/crons": "2.129.2",
|
|
64
|
+
"@stamhoofd/email": "2.129.2",
|
|
65
|
+
"@stamhoofd/excel-writer": "2.129.2",
|
|
66
|
+
"@stamhoofd/logging": "2.129.2",
|
|
67
|
+
"@stamhoofd/models": "2.129.2",
|
|
68
|
+
"@stamhoofd/object-differ": "2.129.2",
|
|
69
|
+
"@stamhoofd/queues": "2.129.2",
|
|
70
|
+
"@stamhoofd/sql": "2.129.2",
|
|
71
|
+
"@stamhoofd/structures": "2.129.2",
|
|
72
|
+
"@stamhoofd/types": "2.129.2",
|
|
73
|
+
"@stamhoofd/utility": "2.129.2",
|
|
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.
|
|
94
|
+
"@stamhoofd/test-utils": "2.129.2",
|
|
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": "
|
|
110
|
+
"gitHead": "099a48481fdbd3d661793675ab9e2c24a594b130"
|
|
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 *
|
|
19
|
+
if (lastRun && new Date().getTime() - lastRun.getTime() < 1000 * 60 * 60 * 12) {
|
|
20
20
|
return;
|
|
21
21
|
}
|
|
22
22
|
lastRun = new Date();
|
|
@@ -44,6 +44,6 @@ async function createStripeInvoices() {
|
|
|
44
44
|
const invoicer = new StripeInvoicer({
|
|
45
45
|
secretKey: STAMHOOFD.STRIPE_SECRET_KEY,
|
|
46
46
|
});
|
|
47
|
-
await invoicer.generateAllInvoices(membershipOrganization, { forceLast: Formatter.dateIso(today) === '2026-07-
|
|
47
|
+
await invoicer.generateAllInvoices(membershipOrganization, { forceLast: Formatter.dateIso(today) === '2026-07-04' });
|
|
48
48
|
lastStripeInvoice = new Date();
|
|
49
49
|
}
|
|
@@ -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('
|
|
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({
|
|
@@ -7,7 +7,7 @@ import { SimpleError } from '@simonbackx/simple-errors';
|
|
|
7
7
|
import type { MemberWithUsersRegistrationsAndGroups, Registration } from '@stamhoofd/models';
|
|
8
8
|
import { AuditLog, BalanceItem, Document, Group, Member, MemberFactory, MemberPlatformMembership, MemberResponsibilityRecord, Organization, Platform, RateLimiter, RegistrationPeriod, User } from '@stamhoofd/models';
|
|
9
9
|
import type { MemberResponsibility, MembersBlob } from '@stamhoofd/structures';
|
|
10
|
-
import { AuditLogReplacement, AuditLogReplacementType, AuditLogSource, AuditLogType, EmergencyContact, GroupType, MemberDetails, MemberWithRegistrationsBlob, Parent, PermissionLevel, PlatformMembershipTypeBehaviour, SetupStepType } from '@stamhoofd/structures';
|
|
10
|
+
import { AuditLogReplacement, AuditLogReplacementType, AuditLogSource, AuditLogType, BooleanStatus, EmergencyContact, GroupType, MemberDetails, MemberWithRegistrationsBlob, Parent, PermissionLevel, PlatformMembershipTypeBehaviour, SetupStepType } from '@stamhoofd/structures';
|
|
11
11
|
import { Formatter } from '@stamhoofd/utility';
|
|
12
12
|
|
|
13
13
|
import { Email } from '@stamhoofd/email';
|
|
@@ -33,13 +33,38 @@ type ResponseBody = MembersBlob;
|
|
|
33
33
|
export const securityCodeLimiter = new RateLimiter({
|
|
34
34
|
limits: [
|
|
35
35
|
{
|
|
36
|
-
// Max
|
|
37
|
-
limit:
|
|
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, '
|
|
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
|
|
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 === '
|
|
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) {
|
|
@@ -1064,6 +1110,15 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
|
|
|
1064
1110
|
// Merge data
|
|
1065
1111
|
// NOTE: We use mergeTwoMembers instead of mergeMultipleMembers, because we should never safe 'member' , because that one does not exist in the database
|
|
1066
1112
|
await mergeTwoMembers(duplicate, member);
|
|
1113
|
+
|
|
1114
|
+
// Make sure the user keeps access to this member
|
|
1115
|
+
// const user = Context.user;
|
|
1116
|
+
|
|
1117
|
+
// const { userEmails, parentEmails } = MemberUserSyncer.getMemberAccessEmails(duplicate.details);
|
|
1118
|
+
// if (user && !duplicate.details.calculatedParentsHaveAccess && parentEmails.includes(user.email.toLocaleLowerCase()) && !userEmails.includes(user.email.toLocaleLowerCase())) {
|
|
1119
|
+
// console.log('Setting parents have access to true to maintain access for parent that used security code');
|
|
1120
|
+
// duplicate.details.parentsHaveAccess = BooleanStatus.create({ value: true });
|
|
1121
|
+
// }
|
|
1067
1122
|
return duplicate;
|
|
1068
1123
|
}
|
|
1069
1124
|
}
|
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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('
|
|
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
|
-
|
|
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
|
-
|
|
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('
|
|
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
|
-
|
|
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('
|
|
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
|
-
|
|
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
|
-
|
|
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('
|
|
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
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 '
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
191
|
-
|
|
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
|
-
|
|
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),
|
|
@@ -778,5 +778,139 @@ describe('member merge', () => {
|
|
|
778
778
|
expect(m1.details.email).toBe('example@example.com');
|
|
779
779
|
expect(m1.details.alternativeEmails).toEqual([]);
|
|
780
780
|
});
|
|
781
|
+
|
|
782
|
+
describe('Parents have access get merged', () => {
|
|
783
|
+
test('False other', () => {
|
|
784
|
+
const m1 = new Member();
|
|
785
|
+
const m2 = new Member();
|
|
786
|
+
|
|
787
|
+
const base = MemberDetails.create({
|
|
788
|
+
email: 'example@example.com',
|
|
789
|
+
firstName: 'John',
|
|
790
|
+
lastName: 'Doe',
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
const other = MemberDetails.create({
|
|
794
|
+
email: 'example@example.com',
|
|
795
|
+
firstName: 'John',
|
|
796
|
+
lastName: 'Doe',
|
|
797
|
+
parentsHaveAccess: BooleanStatus.create({ value: false }),
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
m1.details = base.clone();
|
|
801
|
+
m2.details = other.clone();
|
|
802
|
+
|
|
803
|
+
mergeMemberDetails(m1, m2);
|
|
804
|
+
|
|
805
|
+
expect(m1.details.email).toBe('example@example.com');
|
|
806
|
+
expect(m1.details.parentsHaveAccess?.value).toEqual(false);
|
|
807
|
+
});
|
|
808
|
+
test('True other', () => {
|
|
809
|
+
const m1 = new Member();
|
|
810
|
+
const m2 = new Member();
|
|
811
|
+
|
|
812
|
+
const base = MemberDetails.create({
|
|
813
|
+
email: 'example@example.com',
|
|
814
|
+
firstName: 'John',
|
|
815
|
+
lastName: 'Doe',
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
const other = MemberDetails.create({
|
|
819
|
+
email: 'example@example.com',
|
|
820
|
+
firstName: 'John',
|
|
821
|
+
lastName: 'Doe',
|
|
822
|
+
parentsHaveAccess: BooleanStatus.create({ value: true }),
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
m1.details = base.clone();
|
|
826
|
+
m2.details = other.clone();
|
|
827
|
+
|
|
828
|
+
mergeMemberDetails(m1, m2);
|
|
829
|
+
|
|
830
|
+
expect(m1.details.email).toBe('example@example.com');
|
|
831
|
+
expect(m1.details.parentsHaveAccess?.value).toEqual(true);
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
test('Both different other prefer newest other', () => {
|
|
835
|
+
const m1 = new Member();
|
|
836
|
+
const m2 = new Member();
|
|
837
|
+
|
|
838
|
+
const base = MemberDetails.create({
|
|
839
|
+
email: 'example@example.com',
|
|
840
|
+
firstName: 'John',
|
|
841
|
+
lastName: 'Doe',
|
|
842
|
+
parentsHaveAccess: BooleanStatus.create({ value: true, date: new Date(0) }),
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
const other = MemberDetails.create({
|
|
846
|
+
email: 'example@example.com',
|
|
847
|
+
firstName: 'John',
|
|
848
|
+
lastName: 'Doe',
|
|
849
|
+
parentsHaveAccess: BooleanStatus.create({ value: false, date: new Date(1) }),
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
m1.details = base.clone();
|
|
853
|
+
m2.details = other.clone();
|
|
854
|
+
|
|
855
|
+
mergeMemberDetails(m1, m2);
|
|
856
|
+
|
|
857
|
+
expect(m1.details.email).toBe('example@example.com');
|
|
858
|
+
expect(m1.details.parentsHaveAccess?.value).toEqual(false);
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
test('Both different other prefer newest base', () => {
|
|
862
|
+
const m1 = new Member();
|
|
863
|
+
const m2 = new Member();
|
|
864
|
+
|
|
865
|
+
const base = MemberDetails.create({
|
|
866
|
+
email: 'example@example.com',
|
|
867
|
+
firstName: 'John',
|
|
868
|
+
lastName: 'Doe',
|
|
869
|
+
parentsHaveAccess: BooleanStatus.create({ value: true, date: new Date(1) }),
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
const other = MemberDetails.create({
|
|
873
|
+
email: 'example@example.com',
|
|
874
|
+
firstName: 'John',
|
|
875
|
+
lastName: 'Doe',
|
|
876
|
+
parentsHaveAccess: BooleanStatus.create({ value: false, date: new Date(0) }),
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
m1.details = base.clone();
|
|
880
|
+
m2.details = other.clone();
|
|
881
|
+
|
|
882
|
+
mergeMemberDetails(m1, m2);
|
|
883
|
+
|
|
884
|
+
expect(m1.details.email).toBe('example@example.com');
|
|
885
|
+
expect(m1.details.parentsHaveAccess?.value).toEqual(true);
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
test('Both different other prefer newest base 2', () => {
|
|
889
|
+
const m1 = new Member();
|
|
890
|
+
const m2 = new Member();
|
|
891
|
+
|
|
892
|
+
const base = MemberDetails.create({
|
|
893
|
+
email: 'example@example.com',
|
|
894
|
+
firstName: 'John',
|
|
895
|
+
lastName: 'Doe',
|
|
896
|
+
parentsHaveAccess: BooleanStatus.create({ value: false, date: new Date(1) }),
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
const other = MemberDetails.create({
|
|
900
|
+
email: 'example@example.com',
|
|
901
|
+
firstName: 'John',
|
|
902
|
+
lastName: 'Doe',
|
|
903
|
+
parentsHaveAccess: BooleanStatus.create({ value: true, date: new Date(0) }),
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
m1.details = base.clone();
|
|
907
|
+
m2.details = other.clone();
|
|
908
|
+
|
|
909
|
+
mergeMemberDetails(m1, m2);
|
|
910
|
+
|
|
911
|
+
expect(m1.details.email).toBe('example@example.com');
|
|
912
|
+
expect(m1.details.parentsHaveAccess?.value).toEqual(false);
|
|
913
|
+
});
|
|
914
|
+
});
|
|
781
915
|
});
|
|
782
916
|
});
|
|
@@ -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()
|
|
@@ -290,6 +306,12 @@ export function mergeMemberDetails(base: Member, other: Member): void {
|
|
|
290
306
|
'dataPermissions',
|
|
291
307
|
);
|
|
292
308
|
|
|
309
|
+
mergeBooleanStatusIfBaseNotSet(
|
|
310
|
+
baseDetails,
|
|
311
|
+
otherDetails,
|
|
312
|
+
'parentsHaveAccess',
|
|
313
|
+
);
|
|
314
|
+
|
|
293
315
|
// address
|
|
294
316
|
mergeAddress(baseDetails, otherDetails, baseDetails);
|
|
295
317
|
|
|
@@ -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';
|
|
@@ -692,7 +693,7 @@ describe('Helpers.MemberUserSyncer', () => {
|
|
|
692
693
|
]);
|
|
693
694
|
});
|
|
694
695
|
|
|
695
|
-
test('Parents are removed if member turns 18', async () => {
|
|
696
|
+
test.skip('Parents are removed if member turns 18', async () => {
|
|
696
697
|
const member = await new MemberFactory({
|
|
697
698
|
details: MemberDetails.create({
|
|
698
699
|
firstName: 'John',
|
|
@@ -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
|
|
@@ -74,7 +132,8 @@ export class MemberUserSyncerStatic {
|
|
|
74
132
|
// This makes sure we don't inherit permissions and aren't counted as 'being' the member
|
|
75
133
|
await this.linkUser(user.email, member, true);
|
|
76
134
|
}
|
|
77
|
-
} else if (
|
|
135
|
+
} else if (member.details.parentsHaveAccess?.value === false && parentEmails.includes(user.email.toLocaleLowerCase()) && !userEmails.includes(user.email.toLocaleLowerCase())) {
|
|
136
|
+
// Only remove if manually set to false for now
|
|
78
137
|
await this.unlinkUser(user, member);
|
|
79
138
|
}
|
|
80
139
|
}
|
|
@@ -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(
|
|
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;
|