@stamhoofd/backend 2.127.1 → 2.129.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +17 -17
- package/src/crons/mollie-refunds.ts +1 -1
- package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.test.ts +6 -1
- package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +56 -27
- package/src/endpoints/global/members/SendMemberSecurityCodeEndpoint.test.ts +388 -0
- package/src/endpoints/global/members/SendMemberSecurityCodeEndpoint.ts +432 -0
- package/src/endpoints/global/members/shouldCheckIfMemberIsDuplicate.ts +0 -1
- package/src/endpoints/global/registration/PatchUserMembersEndpoint.test.ts +99 -0
- package/src/endpoints/global/registration/PatchUserMembersEndpoint.ts +29 -19
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +3 -6
- package/src/helpers/MemberMerger.ts +16 -0
- package/src/helpers/MemberUserSyncer.test.ts +64 -1
- package/src/helpers/MemberUserSyncer.ts +61 -22
- package/src/helpers/StripeInvoicer.ts +1 -1
- package/src/migrations/1783097842-default-member-security-code-email-template.sql +3 -0
- package/src/{migrations/1761665607-sync-member-users.ts → seeds/1783100286-sync-member-users-security-codes.ts} +2 -7
- package/src/services/SMSService.test.ts +48 -0
- package/src/services/SMSService.ts +101 -0
- package/tests/helpers/SMSMocker.ts +61 -0
- package/tests/init/index.ts +1 -0
- package/tests/init/initSMSApi.ts +14 -0
|
@@ -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,30 +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
|
-
import basex from 'base-x';
|
|
9
|
-
import crypto from 'crypto';
|
|
10
8
|
import { AuditLogService } from '../services/AuditLogService.js';
|
|
11
9
|
|
|
12
|
-
|
|
13
|
-
|
|
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
|
+
}
|
|
14
35
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
+
}
|
|
26
52
|
|
|
27
|
-
export class MemberUserSyncerStatic {
|
|
28
53
|
/**
|
|
29
54
|
* Call when:
|
|
30
55
|
* - responsibilities have changed
|
|
@@ -40,6 +65,22 @@ export class MemberUserSyncerStatic {
|
|
|
40
65
|
throw new Error('Failed to load users for member ' + member.id);
|
|
41
66
|
}
|
|
42
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
|
+
|
|
43
84
|
const { userEmails, parentEmails, unverifiedEmails, allEmails } = this.getMemberAccessEmails(member.details);
|
|
44
85
|
|
|
45
86
|
// Make sure all these users have access to the member
|
|
@@ -97,12 +138,10 @@ export class MemberUserSyncerStatic {
|
|
|
97
138
|
}
|
|
98
139
|
|
|
99
140
|
// Generate security code (only for userMode platform)
|
|
100
|
-
if (
|
|
141
|
+
if (member.details.securityCode === null) {
|
|
101
142
|
console.log('Generating security code for member ' + member.id);
|
|
102
143
|
|
|
103
|
-
|
|
104
|
-
const code = customBase.encode(await randomBytes(100)).toUpperCase().substring(0, length);
|
|
105
|
-
member.details.securityCode = code;
|
|
144
|
+
member.details.securityCode = await Member.generateSecurityCode();
|
|
106
145
|
await member.save();
|
|
107
146
|
}
|
|
108
147
|
});
|
|
@@ -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;
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
INSERT INTO `email_templates` (`id`, `subject`, `groupId`, `webshopId`, `organizationId`, `type`, `text`, `html`, `json`, `updatedAt`, `createdAt`) VALUES
|
|
2
|
+
('7c9e6a41-2b8d-4f3a-9e21-5a1c3b4d5e6f', 'Beveiligingscode voor {{firstNameMember}}', NULL, NULL, NULL, 'MemberSecurityCode', '{{greeting}}\n\nJij of een gezinslid ({{requesterEmail}}) vroeg de beveiligingscode op van {{firstNameMember}} bij {{organizationName}}. Met deze code kan je toegang krijgen tot dit lid:\n\n{{securityCode}}Heb je dit niet aangevraagd? Dan kan je deze e-mail veilig negeren.\n\nMet vriendelijke groeten,\n{{organizationName}}\n\nIk wil deze mails niet meer ontvangen ({{unsubscribeUrl}})', '<!DOCTYPE html>\n<html>\n\n<head>\n<meta charset=\"utf-8\" />\n<meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\" />\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1.0\" />\n<title>Beveiligingscode voor {{firstNameMember}}</title>\n<style type=\"text/css\">body {\n color: #000716;\n color: var(--color-dark, #000716);\n font-family: -apple-system-body, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\";\n font-size: 12pt;\n line-height: 1.4;\n}\n\np {\n margin: 0;\n padding: 0;\n line-height: 1.4;\n}\n\np.description {\n color: var(--color-gray-4, #5e5e5e);\n font-size: 10pt;\n}\np.description a, p.description a:link, p.description a:visited, p.description a:active, p.description a:hover {\n text-decoration: underline;\n color: var(--color-gray-4, #5e5e5e);\n}\n\nstrong {\n font-weight: bold;\n}\n\nem {\n font-style: italic;\n}\n\nh1 {\n font-size: 30px;\n font-weight: bold;\n line-height: 1.2;\n}\n@media (max-width: 350px) {\n h1 {\n font-size: 24px;\n }\n}\nh1 {\n margin: 0;\n padding: 0;\n}\n\nh2 {\n font-size: 20px;\n line-height: 1.2;\n font-weight: bold;\n margin: 0;\n padding: 0;\n}\n\nh3 {\n font-size: 16px;\n line-height: 1.2;\n font-weight: bold;\n margin: 0;\n padding: 0;\n}\n\nh4 {\n line-height: 1.2;\n font-weight: 500;\n margin: 0;\n padding: 0;\n}\n\nol, ul {\n list-style-position: outside;\n padding-left: 30px;\n}\n\nhr {\n height: 1px;\n background: var(--color-border, var(--color-gray-2, #dcdcdc));\n border-radius: 1px;\n padding: 0;\n margin: 20px 0;\n outline: none;\n border: 0;\n}\n\n.button {\n touch-action: inherit;\n user-select: auto;\n cursor: pointer;\n display: inline-block !important;\n line-height: 42px;\n font-size: 16px;\n font-weight: bold;\n text-box-trim: none;\n}\n.button:active {\n transform: none;\n}\n\nimg {\n max-width: 100%;\n height: auto;\n}\n\na, a:link, a:visited, a:active, a:hover {\n text-decoration: underline;\n color: blue;\n}\n\n.email-data-table {\n width: 100%;\n border-collapse: collapse;\n}\n.email-data-table th, .email-data-table td {\n text-align: left;\n padding: 10px 10px 10px 0;\n border-bottom: 1px solid var(--color-border, var(--color-gray-2, #dcdcdc));\n vertical-align: middle;\n}\n.email-data-table th:last-child, .email-data-table td:last-child {\n text-align: right;\n padding-right: 0;\n}\n.email-data-table td.price {\n white-space: nowrap;\n}\n.email-data-table thead {\n font-weight: bold;\n}\n.email-data-table thead th {\n font-size: 10pt;\n}\n.email-data-table h4 ~ p {\n padding-top: 3px;\n opacity: 0.8;\n font-size: 11pt;\n}\n\n.style-inline-code {\n font-family: monospace;\n white-space: pre-wrap;\n display: inline-block;\n letter-spacing: 1%;\n}\n\n.style-code-large {\n font-family: monospace;\n white-space: pre-wrap;\n font-size: 16pt;\n letter-spacing: 2px;\n background: #f2f2f2;\n padding: 6px 10px;\n border-radius: 6px;\n display: inline-block;\n}\n\n.email-style-description-small {\n font-size: 14px;\n line-height: 1.5;\n font-weight: normal;\n color: var(--color-gray-4, #5e5e5e);\n font-variation-settings: \"opsz\" 19;\n}\n\n.email-style-title-list {\n font-size: 16px;\n line-height: 1.3;\n font-weight: 500;\n}\n.email-style-title-list + p {\n padding-top: 3px;\n}\n\n.email-style-title-prefix-list {\n font-size: 11px;\n line-height: 1.5;\n font-weight: bold;\n color: {{primaryColor}};\n text-transform: uppercase;\n margin-bottom: 3px;\n}\n.email-style-title-prefix-list.error {\n color: #f0153d;\n}\n\n.email-style-price-base, .email-style-discount-price, .email-style-discount-old-price, .email-style-price {\n font-size: 15px;\n line-height: 1.4;\n font-weight: 500;\n font-variant-numeric: tabular-nums;\n}\n.email-style-price-base.disabled, .disabled.email-style-discount-price, .disabled.email-style-discount-old-price, .disabled.email-style-price {\n opacity: 0.6;\n}\n.email-style-price-base.negative, .negative.email-style-discount-price, .negative.email-style-discount-old-price, .negative.email-style-price {\n color: #f0153d;\n}\n\n.email-style-price {\n font-weight: bold;\n color: {{primaryColor}};\n}\n\n.email-style-discount-old-price {\n text-decoration: line-through;\n color: var(--color-gray-4, #5e5e5e);\n}\n\n.email-style-discount-price {\n font-weight: bold;\n color: #ff4747;\n margin-left: 5px;\n}\n\n.pre-wrap {\n white-space: pre-wrap;\n} hr {height: 2px;background: #e7e7e7; border-radius: 1px; padding: 0; margin: 20px 0; outline: none; border: 0;} .button.primary { margin: 0; text-decoration: none; font-size: 16px; font-weight: bold; color: {{primaryColorContrast}}; padding: 0 27px; line-height: 42px; background: {{primaryColor}}; text-align: center; border-radius: 7px; touch-action: manipulation; display: inline-block; transition: 0.2s transform, 0.2s opacity; } .button.primary:active { transform: scale(0.95, 0.95); } .inline-link, .inline-link:link, .inline-link:visited, .inline-link:active, .inline-link:hover { margin: 0; text-decoration: underline; font-size: inherit; font-weight: inherit; color: inherit; touch-action: manipulation; } .inline-link:active { opacity: 0.5; } .description { color: #5e5e5e; } </style>\n</head>\n\n<body>\n<p style=\"margin: 0; padding: 0; line-height: 1.4;\">{{greeting}}</p><p style=\"margin: 0; padding: 0; line-height: 1.4;\"><br></p><p style=\"margin: 0; padding: 0; line-height: 1.4;\">Jij of een gezinslid ({{requesterEmail}}) vroeg de beveiligingscode op van {{firstNameMember}} bij {{organizationName}}. Met deze code kan je toegang krijgen tot dit lid:</p><p style=\"margin: 0; padding: 0; line-height: 1.4;\"><br></p><div data-type=\"smartVariableBlock\" data-id=\"securityCode\">{{securityCode}}</div><p class=\"description\" style=\"color: #5e5e5e;\">Heb je dit niet aangevraagd? Dan kan je deze e-mail veilig negeren.</p><p style=\"margin: 0; padding: 0; line-height: 1.4;\"><br></p><p style=\"margin: 0; padding: 0; line-height: 1.4;\">Met vriendelijke groeten,</p><p style=\"margin: 0; padding: 0; line-height: 1.4;\">{{organizationName}}</p><p style=\"margin: 0; padding: 0; line-height: 1.4;\"><br></p><hr style=\"height: 2px;background: #e7e7e7; border-radius: 1px; padding: 0; margin: 20px 0; outline: none; border: 0;\"><p style=\"margin: 0; padding: 0; line-height: 1.4;\"><a class=\"inline-link\" href=\"{{unsubscribeUrl}}\" target=\"\" style=\"margin: 0; text-decoration: underline; font-size: inherit; font-weight: inherit; color: inherit; touch-action: manipulation;\">Ik wil deze mails niet meer ontvangen</a></p><p style=\"margin: 0; padding: 0; line-height: 1.4;\"><br></p>\n</body>\n\n</html>', '{\"value\": {\"type\": \"doc\", \"content\": [{\"type\": \"paragraph\", \"content\": [{\"type\": \"smartVariable\", \"attrs\": {\"id\": \"greeting\"}}]}, {\"type\": \"paragraph\"}, {\"type\": \"paragraph\", \"content\": [{\"text\": \"Jij of een gezinslid (\", \"type\": \"text\"}, {\"type\": \"smartVariable\", \"attrs\": {\"id\": \"requesterEmail\"}}, {\"text\": \") vroeg de beveiligingscode op van \", \"type\": \"text\"}, {\"type\": \"smartVariable\", \"attrs\": {\"id\": \"firstNameMember\"}}, {\"text\": \" bij \", \"type\": \"text\"}, {\"type\": \"smartVariable\", \"attrs\": {\"id\": \"organizationName\"}}, {\"text\": \". Met deze code kan je toegang krijgen tot dit lid:\", \"type\": \"text\"}]}, {\"type\": \"paragraph\"}, {\"type\": \"smartVariableBlock\", \"attrs\": {\"id\": \"securityCode\"}}, {\"type\": \"descriptiveText\", \"content\": [{\"text\": \"Heb je dit niet aangevraagd? Dan kan je deze e-mail veilig negeren.\", \"type\": \"text\"}]}, {\"type\": \"paragraph\"}, {\"type\": \"paragraph\", \"content\": [{\"text\": \"Met vriendelijke groeten,\", \"type\": \"text\"}]}, {\"type\": \"paragraph\", \"content\": [{\"type\": \"smartVariable\", \"attrs\": {\"id\": \"organizationName\"}}]}, {\"type\": \"paragraph\"}, {\"type\": \"horizontalRule\"}, {\"type\": \"paragraph\", \"content\": [{\"type\": \"smartButtonInline\", \"attrs\": {\"id\": \"unsubscribeUrl\"}, \"content\": [{\"text\": \"Ik wil deze mails niet meer ontvangen\", \"type\": \"text\"}]}]}, {\"type\": \"paragraph\"}]}, \"version\": 401}', '2026-07-03 15:41:24', '2026-07-03 15:41:24')
|
|
3
|
+
ON DUPLICATE KEY UPDATE id=id;
|
|
@@ -6,20 +6,15 @@ import { MemberUserSyncer } from '../helpers/MemberUserSyncer.js';
|
|
|
6
6
|
import { allSettledButThrowFirst, SeedTools } from '../helpers/SeedTools.js';
|
|
7
7
|
|
|
8
8
|
export default new Migration(async () => {
|
|
9
|
-
if (STAMHOOFD.environment
|
|
9
|
+
if (STAMHOOFD.environment === 'test') {
|
|
10
10
|
console.log('skipped in tests');
|
|
11
11
|
return;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
if (STAMHOOFD.platformName.toLowerCase() !== 'stamhoofd') {
|
|
15
|
-
console.log('skipped for platform (only runs for Stamhoofd): ' + STAMHOOFD.platformName);
|
|
16
|
-
return;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
14
|
const result = await logger.setContext({ tags: ['silent-seed', 'seed'] }, async () => {
|
|
20
15
|
return await SeedTools.loopBatched({
|
|
21
16
|
query: Member.select('id'),
|
|
22
|
-
batchSize:
|
|
17
|
+
batchSize: 50,
|
|
23
18
|
batchAction: async (rawMembers) => {
|
|
24
19
|
const membersWithRegistrations = await Member.getBlobByIds(...rawMembers.map(m => m.id));
|
|
25
20
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { STExpect, TestUtils } from '@stamhoofd/test-utils';
|
|
2
|
+
import { initSMSApi } from '../../tests/init/index.js';
|
|
3
|
+
import { SMSService } from './SMSService.js';
|
|
4
|
+
|
|
5
|
+
describe('SMSService', () => {
|
|
6
|
+
describe('configuration', () => {
|
|
7
|
+
test('is configured when GATEWAYAPI_TOKEN is set', () => {
|
|
8
|
+
// The test environment configures GATEWAYAPI_TOKEN by default
|
|
9
|
+
expect(SMSService.isConfigured).toBe(true);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test('is not configured when GATEWAYAPI_TOKEN is missing', () => {
|
|
13
|
+
TestUtils.setEnvironment('GATEWAYAPI_TOKEN', undefined);
|
|
14
|
+
expect(SMSService.isConfigured).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('phone number formatting', () => {
|
|
19
|
+
test('converts to E.164 and msisdn', () => {
|
|
20
|
+
expect(SMSService.toE164('+32 470 12 34 56')).toBe('+32470123456');
|
|
21
|
+
expect(SMSService.toMsisdn('+32 470 12 34 56')).toBe(32470123456);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('throws for invalid phone numbers', () => {
|
|
25
|
+
expect(() => SMSService.toE164('not-a-number')).toThrow(STExpect.errorWithCode('invalid_phone'));
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('sending', () => {
|
|
30
|
+
test('sends via GatewayAPI', async () => {
|
|
31
|
+
const mocker = initSMSApi();
|
|
32
|
+
|
|
33
|
+
await SMSService.send({ to: '+32 470 12 34 56', message: 'Your code is 1234' });
|
|
34
|
+
|
|
35
|
+
expect(mocker.sentMessages.length).toBe(1);
|
|
36
|
+
expect(mocker.lastMessage!.recipient).toBe(32470123456);
|
|
37
|
+
expect(mocker.lastMessage!.message).toBe('Your code is 1234');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('throws sms_not_configured when GATEWAYAPI_TOKEN is missing', async () => {
|
|
41
|
+
TestUtils.setEnvironment('GATEWAYAPI_TOKEN', undefined);
|
|
42
|
+
|
|
43
|
+
await expect(SMSService.send({ to: '+32470123456', message: 'hi' }))
|
|
44
|
+
.rejects
|
|
45
|
+
.toThrow(STExpect.errorWithCode('sms_not_configured'));
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { SimpleError } from '@simonbackx/simple-errors';
|
|
2
|
+
import { Platform } from '@stamhoofd/models';
|
|
3
|
+
import { Country } from '@stamhoofd/types/Country';
|
|
4
|
+
import type { CountryCode } from 'libphonenumber-js';
|
|
5
|
+
import { parsePhoneNumber } from 'libphonenumber-js/max';
|
|
6
|
+
|
|
7
|
+
const GATEWAY_API_URL = 'https://messaging.gatewayapi.eu/mobile/single';
|
|
8
|
+
|
|
9
|
+
export type SMSMessage = {
|
|
10
|
+
/**
|
|
11
|
+
* Phone number, either in international format (e.g. +32 470 12 34 56) or national format (e.g. 0470 12 34 56).
|
|
12
|
+
*/
|
|
13
|
+
to: string;
|
|
14
|
+
message: string;
|
|
15
|
+
/**
|
|
16
|
+
* Default country used to parse national phone numbers (numbers without a country code).
|
|
17
|
+
*/
|
|
18
|
+
defaultCountry?: Country;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export class SMSService {
|
|
22
|
+
static get isConfigured(): boolean {
|
|
23
|
+
return !!STAMHOOFD.GATEWAYAPI_TOKEN;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Convert a phone number to E.164 format (e.g. +32 470 12 34 56 -> +32470123456).
|
|
28
|
+
*/
|
|
29
|
+
static toE164(phone: string, defaultCountry: Country = Country.Belgium): string {
|
|
30
|
+
try {
|
|
31
|
+
const parsed = parsePhoneNumber(phone, defaultCountry as unknown as CountryCode);
|
|
32
|
+
if (parsed && parsed.isValid()) {
|
|
33
|
+
return parsed.number;
|
|
34
|
+
}
|
|
35
|
+
} catch (e) {
|
|
36
|
+
// handled below
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
throw new SimpleError({
|
|
40
|
+
code: 'invalid_phone',
|
|
41
|
+
message: 'Invalid phone number for SMS: ' + phone,
|
|
42
|
+
human: $t(`%ZbO`),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Convert a phone number to the msisdn format expected by GatewayAPI: the full international
|
|
48
|
+
* number without a leading '+' or '00' (e.g. +32 470 12 34 56 -> 32470123456).
|
|
49
|
+
*/
|
|
50
|
+
static toMsisdn(phone: string, defaultCountry: Country = Country.Belgium): number {
|
|
51
|
+
return parseInt(this.toE164(phone, defaultCountry).substring(1), 10);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
static async send(sms: SMSMessage): Promise<void> {
|
|
55
|
+
const token = STAMHOOFD.GATEWAYAPI_TOKEN;
|
|
56
|
+
if (!token) {
|
|
57
|
+
throw new SimpleError({
|
|
58
|
+
code: 'sms_not_configured',
|
|
59
|
+
message: 'SMS gateway is not configured',
|
|
60
|
+
human: $t(`%ZbC`),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const msisdn = this.toMsisdn(sms.to, sms.defaultCountry ?? Country.Belgium);
|
|
65
|
+
|
|
66
|
+
let response: Response;
|
|
67
|
+
try {
|
|
68
|
+
response = await fetch(GATEWAY_API_URL, {
|
|
69
|
+
method: 'POST',
|
|
70
|
+
headers: {
|
|
71
|
+
'Authorization': 'Token ' + token,
|
|
72
|
+
'Content-Type': 'application/json',
|
|
73
|
+
},
|
|
74
|
+
body: JSON.stringify({
|
|
75
|
+
sender: (await Platform.getShared()).config.name,
|
|
76
|
+
message: sms.message,
|
|
77
|
+
recipient: msisdn,
|
|
78
|
+
priority: 'urgent',
|
|
79
|
+
expiration: 'PT10M',
|
|
80
|
+
}),
|
|
81
|
+
signal: AbortSignal.timeout(10000),
|
|
82
|
+
});
|
|
83
|
+
} catch (error) {
|
|
84
|
+
throw new SimpleError({
|
|
85
|
+
code: 'sms_unreachable',
|
|
86
|
+
message: 'Network issue when sending SMS: ' + (error instanceof Error ? error.message : String(error)),
|
|
87
|
+
human: $t(`%Zb8`),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!response.ok) {
|
|
92
|
+
const body = await response.text();
|
|
93
|
+
console.error('Failed to send SMS via GatewayAPI', response.status, body);
|
|
94
|
+
throw new SimpleError({
|
|
95
|
+
code: 'sms_failed',
|
|
96
|
+
message: 'Failed to send SMS via GatewayAPI: ' + response.status + ' ' + body,
|
|
97
|
+
human: $t(`%Zb8`),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import nock from 'nock';
|
|
2
|
+
import { resetNock } from './resetNock.js';
|
|
3
|
+
|
|
4
|
+
export type SentSMS = {
|
|
5
|
+
sender: string;
|
|
6
|
+
message: string;
|
|
7
|
+
recipient: number;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Mocks the GatewayAPI SMS HTTP endpoint (https://messaging.gatewayapi.eu/rest/mtsms).
|
|
12
|
+
*
|
|
13
|
+
* This only intercepts the outgoing HTTP request, so all of the real SMSService code (payload
|
|
14
|
+
* building, authentication, phone number normalization, error handling) runs during the test.
|
|
15
|
+
*/
|
|
16
|
+
export class SMSMocker {
|
|
17
|
+
sentMessages: SentSMS[] = [];
|
|
18
|
+
|
|
19
|
+
#forceFailure = false;
|
|
20
|
+
|
|
21
|
+
reset() {
|
|
22
|
+
this.sentMessages = [];
|
|
23
|
+
this.#forceFailure = false;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Make the next requests fail (server returns a 500).
|
|
28
|
+
*/
|
|
29
|
+
forceFailure() {
|
|
30
|
+
this.#forceFailure = true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
start() {
|
|
34
|
+
nock('https://messaging.gatewayapi.eu')
|
|
35
|
+
.persist()
|
|
36
|
+
.post('/mobile/single')
|
|
37
|
+
.reply((uri: string, requestBody: nock.Body) => {
|
|
38
|
+
if (this.#forceFailure) {
|
|
39
|
+
return [500, { code: '0x0002', message: 'Forced failure' }];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const body = (typeof requestBody === 'string' ? JSON.parse(requestBody) : requestBody) as SentSMS;
|
|
43
|
+
this.sentMessages.push({
|
|
44
|
+
sender: body.sender,
|
|
45
|
+
message: body.message,
|
|
46
|
+
recipient: body.recipient,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return [200, { ids: [this.sentMessages.length] }];
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
stop() {
|
|
54
|
+
this.reset();
|
|
55
|
+
resetNock();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
get lastMessage(): SentSMS | undefined {
|
|
59
|
+
return this.sentMessages[this.sentMessages.length - 1];
|
|
60
|
+
}
|
|
61
|
+
}
|
package/tests/init/index.ts
CHANGED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { TestUtils } from '@stamhoofd/test-utils';
|
|
2
|
+
import { SMSMocker } from '../helpers/SMSMocker.js';
|
|
3
|
+
|
|
4
|
+
export function initSMSApi(): SMSMocker {
|
|
5
|
+
const mocker = new SMSMocker();
|
|
6
|
+
|
|
7
|
+
mocker.start();
|
|
8
|
+
|
|
9
|
+
TestUtils.scheduleAfterThisTest(() => {
|
|
10
|
+
mocker.stop();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
return mocker;
|
|
14
|
+
}
|