@stamhoofd/backend 2.137.4 → 2.138.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.
Files changed (142) hide show
  1. package/package.json +26 -17
  2. package/src/boot.ts +5 -0
  3. package/src/crons/balance-emails.ts +2 -1
  4. package/src/crons/delete-expired-mfa-tokens.ts +35 -0
  5. package/src/crons/drip-emails.ts +2 -1
  6. package/src/crons/index.ts +1 -0
  7. package/src/crons/invoices.ts +5 -3
  8. package/src/crons.ts +4 -2
  9. package/src/email-recipient-loaders/orders.ts +2 -1
  10. package/src/email-recipient-loaders/organizations.ts +3 -2
  11. package/src/email-recipient-loaders/payments.ts +3 -2
  12. package/src/endpoints/auth/ConfirmTOTPEndpoint.ts +84 -0
  13. package/src/endpoints/auth/CreateAdminEndpoint.ts +4 -2
  14. package/src/endpoints/auth/CreateTokenEndpoint.test.ts +61 -1
  15. package/src/endpoints/auth/CreateTokenEndpoint.ts +135 -7
  16. package/src/endpoints/auth/DeletePasskeyEndpoint.ts +61 -0
  17. package/src/endpoints/auth/DeleteTOTPEndpoint.ts +62 -0
  18. package/src/endpoints/auth/DeleteUserEndpoint.ts +1 -1
  19. package/src/endpoints/auth/ForgotPasswordEndpoint.ts +4 -2
  20. package/src/endpoints/auth/GetMFAChallengeEndpoint.ts +56 -0
  21. package/src/endpoints/auth/GetMFAStatusEndpoint.ts +31 -0
  22. package/src/endpoints/auth/GetUserEndpoint.test.ts +32 -1
  23. package/src/endpoints/auth/MFA.security.test.ts +688 -0
  24. package/src/endpoints/auth/MFA.test.ts +1398 -0
  25. package/src/endpoints/auth/OpenIDConnectAuthTokenEndpoint.ts +2 -2
  26. package/src/endpoints/auth/PatchUserEndpoint.ts +6 -3
  27. package/src/endpoints/auth/RegenerateRecoveryCodesEndpoint.ts +51 -0
  28. package/src/endpoints/auth/RegisterPasskeyEndpoint.ts +99 -0
  29. package/src/endpoints/auth/RegisterPasskeyOptionsEndpoint.ts +49 -0
  30. package/src/endpoints/auth/RetryEmailVerificationEndpoint.ts +2 -1
  31. package/src/endpoints/auth/SetupTOTPEndpoint.ts +54 -0
  32. package/src/endpoints/auth/SignupEndpoint.ts +17 -4
  33. package/src/endpoints/auth/VerifyEmailEndpoint.ts +16 -0
  34. package/src/endpoints/global/email/CreateEmailEndpoint.ts +5 -3
  35. package/src/endpoints/global/email/GetAdminEmailsEndpoint.test.ts +1 -1
  36. package/src/endpoints/global/email/GetAdminEmailsEndpoint.ts +2 -1
  37. package/src/endpoints/global/email/GetEmailEndpoint.ts +2 -1
  38. package/src/endpoints/global/email/GetUserEmailsEndpoint.test.ts +1 -1
  39. package/src/endpoints/global/email/GetUserEmailsEndpoint.ts +2 -1
  40. package/src/endpoints/global/email/PatchEmailEndpoint.ts +5 -3
  41. package/src/endpoints/global/email-recipients/GetEmailRecipientsEndpoint.ts +2 -1
  42. package/src/endpoints/global/email-recipients/RetryEmailRecipientEndpoint.ts +2 -1
  43. package/src/endpoints/global/files/ExportToExcelEndpoint.ts +11 -6
  44. package/src/endpoints/global/files/UploadFile.test.ts +206 -0
  45. package/src/endpoints/global/files/UploadFile.ts +31 -6
  46. package/src/endpoints/global/files/UploadImage.test.ts +177 -0
  47. package/src/endpoints/global/files/UploadImage.ts +23 -4
  48. package/src/endpoints/global/files/upload-security.test.ts +837 -0
  49. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +1 -1
  50. package/src/endpoints/global/members/SendMemberSecurityCodeEndpoint.ts +2 -1
  51. package/src/endpoints/global/organizations/CreateOrganizationEndpoint.ts +2 -1
  52. package/src/endpoints/global/platform/GetPlatformAdminsEndpoint.ts +4 -1
  53. package/src/endpoints/global/platform/PatchPlatformEnpoint.test.ts +35 -1
  54. package/src/endpoints/global/platform/PatchPlatformEnpoint.ts +8 -0
  55. package/src/endpoints/global/platform/SignOutPlatformAdminsEndpoint.test.ts +92 -0
  56. package/src/endpoints/global/platform/SignOutPlatformAdminsEndpoint.ts +43 -0
  57. package/src/endpoints/organization/dashboard/documents/GetDocumentTemplateXML.ts +2 -1
  58. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +1 -0
  59. package/src/endpoints/organization/dashboard/organization/SetOrganizationDomainEndpoint.ts +3 -2
  60. package/src/endpoints/organization/dashboard/receivable-balances/ChargeReceivableBalancesEndpoint.ts +3 -2
  61. package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.test.ts +32 -3
  62. package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.ts +4 -1
  63. package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.test.ts +100 -0
  64. package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.ts +4 -1
  65. package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.test.ts +5 -5
  66. package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.ts +5 -3
  67. package/src/endpoints/organization/dashboard/users/SignOutOrganizationAdminsEndpoint.test.ts +160 -0
  68. package/src/endpoints/organization/dashboard/users/SignOutOrganizationAdminsEndpoint.ts +44 -0
  69. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +4 -3
  70. package/src/endpoints/organization/shared/GetDocumentHtml.ts +2 -1
  71. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +4 -3
  72. package/src/excel-loaders/balance-items.ts +1 -1
  73. package/src/excel-loaders/event-notifications.ts +6 -7
  74. package/src/excel-loaders/members.test.ts +2 -2
  75. package/src/excel-loaders/members.ts +9 -10
  76. package/src/excel-loaders/organizations.ts +14 -20
  77. package/src/excel-loaders/payments.ts +1 -1
  78. package/src/excel-loaders/platform-memberships.ts +7 -7
  79. package/src/excel-loaders/platform-sheets.test.ts +113 -0
  80. package/src/excel-loaders/receivable-balances.ts +1 -1
  81. package/src/excel-loaders/registrations.ts +9 -9
  82. package/src/helpers/AdminPermissionChecker.ts +1 -1
  83. package/src/helpers/AuthenticatedStructures.ts +21 -10
  84. package/src/helpers/Context.ts +114 -4
  85. package/src/helpers/EmailBuilder.test.ts +287 -0
  86. package/src/helpers/EmailBuilder.ts +811 -0
  87. package/src/helpers/EmailResumer.ts +2 -1
  88. package/src/helpers/ForwardHandler.ts +2 -1
  89. package/src/helpers/MFAEncryption.ts +34 -0
  90. package/src/helpers/MembershipCharger.ts +3 -2
  91. package/src/helpers/RecoveryCodeHelper.ts +81 -0
  92. package/src/helpers/TOTPHelper.ts +79 -0
  93. package/src/helpers/TenantContext.test.ts +96 -0
  94. package/src/helpers/TenantContext.ts +67 -0
  95. package/src/helpers/TwoFactorHelper.ts +356 -0
  96. package/src/helpers/WebauthnHelper.ts +211 -0
  97. package/src/helpers/data/aaguids.json +1006 -0
  98. package/src/middleware/TenantScopeMiddleware.test.ts +41 -0
  99. package/src/middleware/TenantScopeMiddleware.ts +24 -0
  100. package/src/seeds/1785417302-fill-user-last-active-at.sql +6 -0
  101. package/src/services/AdminSessionService.ts +41 -0
  102. package/src/services/BalanceItemService.ts +2 -1
  103. package/src/services/DocumentRenderService.test.ts +229 -0
  104. package/src/services/DocumentRenderService.ts +180 -0
  105. package/src/services/EmailPreviewService.test.ts +300 -0
  106. package/src/services/EmailPreviewService.ts +239 -0
  107. package/src/services/EmailSendService.test.ts +1218 -0
  108. package/src/services/EmailSendService.ts +705 -0
  109. package/src/services/EventNotificationService.ts +2 -1
  110. package/src/services/FileSignService.test.ts +85 -0
  111. package/src/services/FileSignService.ts +23 -1
  112. package/src/services/InvoicePdfService.ts +3 -3
  113. package/src/services/InvoiceService.ts +4 -2
  114. package/src/services/InvoiceXMLService.ts +2 -1
  115. package/src/services/OrderService.test.ts +308 -0
  116. package/src/services/OrderService.ts +215 -0
  117. package/src/services/OrganizationAdminService.test.ts +47 -0
  118. package/src/services/OrganizationAdminService.ts +139 -0
  119. package/src/services/OrganizationDNSService.test.ts +177 -0
  120. package/src/services/OrganizationDNSService.ts +282 -0
  121. package/src/services/OrganizationEmailService.test.ts +57 -0
  122. package/src/services/OrganizationEmailService.ts +212 -0
  123. package/src/services/PasswordForgotService.test.ts +99 -0
  124. package/src/services/PasswordForgotService.ts +38 -0
  125. package/src/services/PaymentService.ts +5 -3
  126. package/src/services/PlatformMembershipService.test.ts +180 -0
  127. package/src/services/PlatformMembershipService.ts +281 -4
  128. package/src/services/ReferralService.ts +3 -2
  129. package/src/services/RegistrationService.ts +4 -2
  130. package/src/services/SSOService.ts +106 -14
  131. package/src/services/STPackageService.test.ts +191 -0
  132. package/src/services/STPackageService.ts +115 -1
  133. package/src/services/TwoFactorAuditLogService.ts +64 -0
  134. package/src/services/VerificationCodeService.test.ts +71 -0
  135. package/src/services/VerificationCodeService.ts +101 -0
  136. package/tests/e2e/api-rate-limits.test.ts +1 -1
  137. package/tests/e2e/documents.test.ts +2 -1
  138. package/tests/e2e/private-files.test.ts +77 -14
  139. package/tests/helpers/MFATestHelper.ts +42 -0
  140. package/tests/helpers/TestServer.ts +4 -0
  141. package/tests/helpers/index.ts +1 -0
  142. package/tsconfig.build.json +2 -1
@@ -0,0 +1,287 @@
1
+ import { EmailMocker } from '@stamhoofd/email';
2
+ import { EmailContent, EmailTemplateType, Recipient, Replacement } from '@stamhoofd/structures';
3
+ import { Country } from '@stamhoofd/types/Country';
4
+ import { Language } from '@stamhoofd/types/Language';
5
+ import { TestUtils } from '@stamhoofd/test-utils';
6
+ import type { Organization, RegistrationPeriod } from '@stamhoofd/models';
7
+ import { Email, EmailTemplateFactory, OrganizationFactory, RegistrationPeriodFactory } from '@stamhoofd/models';
8
+ import { removeUnusedReplacements, sendEmailTemplate } from './EmailBuilder.js';
9
+
10
+ describe('sendEmailTemplate with translations', () => {
11
+ let period: RegistrationPeriod;
12
+ let organization: Organization;
13
+
14
+ beforeAll(async () => {
15
+ period = await new RegistrationPeriodFactory({
16
+ startDate: new Date(2023, 0, 1),
17
+ endDate: new Date(2023, 11, 31),
18
+ }).create();
19
+ });
20
+
21
+ beforeEach(async () => {
22
+ organization = await new OrganizationFactory({ period }).create();
23
+ });
24
+
25
+ const type = EmailTemplateType.ForgotPassword;
26
+
27
+ test('each recipient receives the content in its own language, with the default as fallback', async () => {
28
+ await new EmailTemplateFactory({
29
+ organization,
30
+ type,
31
+ subject: 'Default subject',
32
+ html: '<p>Default html</p>',
33
+ text: 'Default text',
34
+ language: Language.Dutch,
35
+ translations: new Map([
36
+ [Language.French, EmailContent.create({ subject: 'Sujet français', html: '<p>Français</p>', text: 'Français' })],
37
+ ]),
38
+ }).create();
39
+
40
+ await sendEmailTemplate(organization, {
41
+ recipients: [
42
+ Recipient.create({ email: 'french@example.com', language: Language.French }),
43
+ Recipient.create({ email: 'dutch@example.com', language: Language.Dutch }),
44
+ Recipient.create({ email: 'english@example.com', language: Language.English }),
45
+ Recipient.create({ email: 'unknown@example.com' }),
46
+ ],
47
+ template: { type },
48
+ type: 'transactional',
49
+ });
50
+
51
+ const emails = await EmailMocker.transactional.getSucceededEmails();
52
+ expect(emails).toHaveLength(4);
53
+
54
+ const french = emails.find(e => e.to.includes('french@example.com'))!;
55
+ expect(french.subject).toBe('Sujet français');
56
+ expect(french.html).toContain('Français');
57
+
58
+ // Dutch is the default language: its content lives in the default content, not in the translations
59
+ const dutch = emails.find(e => e.to.includes('dutch@example.com'))!;
60
+ expect(dutch.subject).toBe('Default subject');
61
+ expect(dutch.html).toContain('Default html');
62
+
63
+ // English has no translation: falls back to the default content
64
+ const english = emails.find(e => e.to.includes('english@example.com'))!;
65
+ expect(english.subject).toBe('Default subject');
66
+ expect(english.html).toContain('Default html');
67
+
68
+ const unknown = emails.find(e => e.to.includes('unknown@example.com'))!;
69
+ expect(unknown.subject).toBe('Default subject');
70
+ });
71
+
72
+ test('generates recipient replacements in the recipient language', async () => {
73
+ // French must be a valid locale, otherwise it gets corrected to the default language
74
+ TestUtils.setEnvironment('locales', { [Country.Belgium]: [Language.Dutch, Language.French] });
75
+
76
+ // The unsubscribe URL is localized per recipient (it is not part of the translatable content)
77
+ await new EmailTemplateFactory({
78
+ organization,
79
+ type,
80
+ subject: 'Subject',
81
+ html: '<p>{{greeting}} {{unsubscribeUrl}}</p>',
82
+ text: '{{greeting}} {{unsubscribeUrl}}',
83
+ }).create();
84
+
85
+ await sendEmailTemplate(organization, {
86
+ recipients: [
87
+ Recipient.create({ email: 'french@example.com', language: Language.French }),
88
+ Recipient.create({ email: 'dutch@example.com', language: Language.Dutch }),
89
+ Recipient.create({ email: 'unknown@example.com' }),
90
+ ],
91
+ template: { type },
92
+ type: 'transactional',
93
+ });
94
+
95
+ const emails = await EmailMocker.transactional.getSucceededEmails();
96
+ const french = emails.find(e => e.to.includes('french@example.com'))!;
97
+ const dutch = emails.find(e => e.to.includes('dutch@example.com'))!;
98
+ const unknown = emails.find(e => e.to.includes('unknown@example.com'))!;
99
+
100
+ // The unsubscribe page URL points to the recipient's localized page
101
+ expect(french.html).toContain('/fr-BE/unsubscribe');
102
+ expect(dutch.html).toContain('/nl-BE/unsubscribe');
103
+ // No language set: falls back to the ambient (default) locale
104
+ expect(unknown.html).toContain('/nl-BE/unsubscribe');
105
+ });
106
+
107
+ test('a missing language never falls back to the translation of a different template', async () => {
108
+ // Platform level template with a French translation
109
+ await new EmailTemplateFactory({
110
+ type,
111
+ subject: 'Platform subject',
112
+ html: '<p>Platform html</p>',
113
+ text: 'Platform text',
114
+ language: Language.Dutch,
115
+ translations: new Map([
116
+ [Language.French, EmailContent.create({ subject: 'Sujet plateforme', html: '<p>Plateforme</p>', text: 'Plateforme' })],
117
+ ]),
118
+ }).create();
119
+
120
+ // Organization level template without any translations
121
+ await new EmailTemplateFactory({
122
+ organization,
123
+ type,
124
+ subject: 'Organization subject',
125
+ html: '<p>Organization html</p>',
126
+ text: 'Organization text',
127
+ }).create();
128
+
129
+ await sendEmailTemplate(organization, {
130
+ recipients: [
131
+ Recipient.create({ email: 'french@example.com', language: Language.French }),
132
+ ],
133
+ template: { type },
134
+ type: 'transactional',
135
+ });
136
+
137
+ const emails = await EmailMocker.transactional.getSucceededEmails();
138
+ expect(emails).toHaveLength(1);
139
+
140
+ // The organization template wins, and its default content is used for French:
141
+ // never the French translation of the platform template
142
+ expect(emails[0].subject).toBe('Organization subject');
143
+ expect(emails[0].html).toContain('Organization html');
144
+ });
145
+
146
+ test('setFromTemplate copies only the default language of the template onto the email if no language chosen for email', async () => {
147
+ await new EmailTemplateFactory({
148
+ organization,
149
+ type: EmailTemplateType.SavedMembersEmail,
150
+ subject: 'Default subject',
151
+ html: '<p>Default html</p>',
152
+ text: 'Default text',
153
+ language: Language.Dutch,
154
+ translations: new Map([
155
+ [Language.French, EmailContent.create({ subject: 'Sujet français', html: '<p>Français</p>', text: 'Français' })],
156
+ ]),
157
+ }).create();
158
+
159
+ const email = new Email();
160
+ email.organizationId = organization.id;
161
+ expect(await email.setFromTemplate(EmailTemplateType.SavedMembersEmail)).toBe(true);
162
+
163
+ expect(email.subject).toBe('Default subject');
164
+ expect(email.language).toBe(null);
165
+ expect(email.translations.size).toBe(0);
166
+ });
167
+
168
+ test('setFromTemplate copies only the correct language of the template onto the email', async () => {
169
+ await new EmailTemplateFactory({
170
+ organization,
171
+ type: EmailTemplateType.SavedMembersEmail,
172
+ subject: 'Default subject',
173
+ html: '<p>Default html</p>',
174
+ text: 'Default text',
175
+ language: Language.Dutch,
176
+ translations: new Map([
177
+ [Language.French, EmailContent.create({ subject: 'Sujet français', html: '<p>Français</p>', text: 'Français' })],
178
+ ]),
179
+ }).create();
180
+
181
+ const email = new Email();
182
+ email.language = Language.French;
183
+ email.organizationId = organization.id;
184
+ expect(await email.setFromTemplate(EmailTemplateType.SavedMembersEmail)).toBe(true);
185
+
186
+ expect(email.subject).toBe('Sujet français');
187
+ expect(email.language).toBe(Language.French);
188
+ expect(email.translations.size).toBe(0);
189
+ });
190
+
191
+ test('setFromTemplate copies only the default language of the template onto the email if languages match', async () => {
192
+ await new EmailTemplateFactory({
193
+ organization,
194
+ type: EmailTemplateType.SavedMembersEmail,
195
+ subject: 'Default subject',
196
+ html: '<p>Default html</p>',
197
+ text: 'Default text',
198
+ language: Language.Dutch,
199
+ translations: new Map([
200
+ [Language.French, EmailContent.create({ subject: 'Sujet français', html: '<p>Français</p>', text: 'Français' })],
201
+ ]),
202
+ }).create();
203
+
204
+ const email = new Email();
205
+ email.language = Language.Dutch;
206
+ email.organizationId = organization.id;
207
+ expect(await email.setFromTemplate(EmailTemplateType.SavedMembersEmail)).toBe(true);
208
+
209
+ expect(email.subject).toBe('Default subject');
210
+ expect(email.language).toBe(Language.Dutch);
211
+ expect(email.translations.size).toBe(0);
212
+ });
213
+
214
+ test('replaceAll is applied to the html of every language, not only the default', async () => {
215
+ await new EmailTemplateFactory({
216
+ organization,
217
+ type,
218
+ subject: 'Subject',
219
+ // The same placeholder appears in both the default and the translated html
220
+ html: '<p>Default __PLACEHOLDER__</p>',
221
+ text: 'Default __PLACEHOLDER__',
222
+ language: Language.Dutch,
223
+ translations: new Map([
224
+ [Language.French, EmailContent.create({ subject: 'Sujet', html: '<p>Français __PLACEHOLDER__</p>', text: 'Français __PLACEHOLDER__' })],
225
+ ]),
226
+ }).create();
227
+
228
+ await sendEmailTemplate(organization, {
229
+ recipients: [
230
+ Recipient.create({ email: 'french@example.com', language: Language.French }),
231
+ Recipient.create({ email: 'default@example.com' }),
232
+ ],
233
+ template: { type },
234
+ type: 'transactional',
235
+ replaceAll: [{ from: '__PLACEHOLDER__', to: 'REPLACED' }],
236
+ });
237
+
238
+ const emails = await EmailMocker.transactional.getSucceededEmails();
239
+ const french = emails.find(e => e.to.includes('french@example.com'))!;
240
+ const fallback = emails.find(e => e.to.includes('default@example.com'))!;
241
+
242
+ // The replaceAll must reach the translated html too, otherwise the placeholder leaks
243
+ expect(french.html).toContain('Français REPLACED');
244
+ expect(french.html).not.toContain('__PLACEHOLDER__');
245
+ expect(fallback.html).toContain('Default REPLACED');
246
+ expect(fallback.html).not.toContain('__PLACEHOLDER__');
247
+ });
248
+ });
249
+
250
+ describe('Email.getCombinedHtml', () => {
251
+ test('combines the default html with the html of every translation', () => {
252
+ const email = new Email();
253
+ email.html = '<p>Default {{signInUrl}}</p>';
254
+ email.translations = new Map([
255
+ [Language.French, EmailContent.create({ html: '<p>Français {{balanceTable}}</p>' })],
256
+ ]);
257
+
258
+ const combined = email.getCombinedHtml();
259
+ expect(combined).toContain('{{signInUrl}}');
260
+ expect(combined).toContain('{{balanceTable}}');
261
+ });
262
+
263
+ test('keeps a replacement that is only used inside a translation', () => {
264
+ const email = new Email();
265
+ // The default html uses signInUrl, only the French translation uses balanceTable
266
+ email.html = '<p>Default {{signInUrl}}</p>';
267
+ email.translations = new Map([
268
+ [Language.French, EmailContent.create({ html: '<p>Français {{balanceTable}}</p>' })],
269
+ ]);
270
+
271
+ const replacements = [
272
+ Replacement.create({ token: 'signInUrl', value: 'https://example.com' }),
273
+ Replacement.create({ token: 'balanceTable', value: '', html: '<table></table>' }),
274
+ Replacement.create({ token: 'outstandingBalance', value: '€ 10' }),
275
+ ];
276
+
277
+ // Using only the default html would wrongly strip balanceTable (used only by the translation)
278
+ const usingDefaultHtml = removeUnusedReplacements(email.html ?? '', replacements).map(r => r.token);
279
+ expect(usingDefaultHtml).not.toContain('balanceTable');
280
+
281
+ // Using the combined html keeps every replacement that any language needs, and still drops the truly unused one
282
+ const usingCombinedHtml = removeUnusedReplacements(email.getCombinedHtml(), replacements).map(r => r.token);
283
+ expect(usingCombinedHtml).toContain('signInUrl');
284
+ expect(usingCombinedHtml).toContain('balanceTable');
285
+ expect(usingCombinedHtml).not.toContain('outstandingBalance');
286
+ });
287
+ });