@stamhoofd/backend 2.127.1 → 2.128.0
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/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +5 -22
- package/src/endpoints/global/members/SendMemberSecurityCodeEndpoint.test.ts +410 -0
- package/src/endpoints/global/members/SendMemberSecurityCodeEndpoint.ts +462 -0
- package/src/endpoints/global/registration/PatchUserMembersEndpoint.test.ts +2 -0
- package/src/endpoints/global/registration/PatchUserMembersEndpoint.ts +16 -1
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +3 -6
- package/src/helpers/MemberUserSyncer.ts +2 -21
- package/src/migrations/1783097842-default-member-security-code-email-template.sql +3 -0
- package/src/{migrations/1761665607-sync-member-users.ts → seeds/1783100286-sync-member-users-security-codes.ts} +2 -7
- package/src/services/SMSService.test.ts +48 -0
- package/src/services/SMSService.ts +101 -0
- package/tests/helpers/SMSMocker.ts +61 -0
- package/tests/init/index.ts +1 -0
- package/tests/init/initSMSApi.ts +14 -0
|
@@ -0,0 +1,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
|
+
}
|