@stamhoofd/backend 2.134.0 → 2.136.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 (55) hide show
  1. package/package.json +18 -17
  2. package/src/crons/cleanup-orphaned-cached-balances.test.ts +88 -0
  3. package/src/crons/cleanup-orphaned-cached-balances.ts +44 -0
  4. package/src/crons/index.ts +1 -0
  5. package/src/email-recipient-loaders/orders.ts +7 -9
  6. package/src/endpoints/admin/memberships/GetChargeMembershipsSummaryEndpoint.test.ts +95 -0
  7. package/src/endpoints/admin/memberships/GetChargeMembershipsSummaryEndpoint.ts +7 -0
  8. package/src/endpoints/auth/VerifyEmailEndpoint.test.ts +154 -0
  9. package/src/endpoints/auth/VerifyEmailEndpoint.ts +4 -0
  10. package/src/endpoints/global/email/CreateEmailEndpoint.ts +5 -2
  11. package/src/endpoints/global/email/GetAdminEmailsEndpoint.ts +1 -1
  12. package/src/endpoints/global/email/GetEmailEndpoint.ts +1 -1
  13. package/src/endpoints/global/email/GetUserEmailsEndpoint.test.ts +131 -1
  14. package/src/endpoints/global/email/GetUserEmailsEndpoint.ts +8 -3
  15. package/src/endpoints/global/email/PatchEmailEndpoint.test.ts +348 -1
  16. package/src/endpoints/global/email/PatchEmailEndpoint.ts +21 -2
  17. package/src/endpoints/global/members/GetMembersEndpoint.test.ts +342 -2
  18. package/src/endpoints/global/registration/RegisterMembersEndpoint.test.ts +150 -0
  19. package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +4 -3
  20. package/src/endpoints/organization/dashboard/email-templates/PatchEmailTemplatesEndpoint.test.ts +271 -3
  21. package/src/endpoints/organization/dashboard/email-templates/PatchEmailTemplatesEndpoint.ts +15 -2
  22. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +2 -0
  23. package/src/endpoints/organization/dashboard/organization/SetUitpasClientCredentialsEndpoint.ts +1 -8
  24. package/src/endpoints/organization/dashboard/webshops/GetWebshopOrdersEndpoint.test.ts +112 -0
  25. package/src/endpoints/organization/dashboard/webshops/PatchWebshopEndpoint.test.ts +49 -0
  26. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.test.ts +71 -0
  27. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +4 -0
  28. package/src/endpoints/organization/webshops/GetWebshopEndpoint.test.ts +11 -0
  29. package/src/endpoints/organization/webshops/OrderConfirmationEmailLanguage.test.ts +156 -0
  30. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.test.ts +68 -1
  31. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +8 -0
  32. package/src/excel-loaders/members.test.ts +59 -0
  33. package/src/excel-loaders/members.ts +17 -0
  34. package/src/helpers/AuthenticatedStructures.ts +9 -0
  35. package/src/helpers/GlobalHelper.ts +1 -1
  36. package/src/helpers/MemberMerger.test.ts +70 -2
  37. package/src/helpers/MemberMerger.ts +43 -1
  38. package/src/helpers/MemberUserSyncer.ts +5 -0
  39. package/src/helpers/MembershipCharger.test.ts +110 -0
  40. package/src/helpers/MembershipCharger.ts +12 -33
  41. package/src/helpers/UitpasTokenRepository.ts +12 -11
  42. package/src/helpers/XlsxTransformerColumnHelper.test.ts +63 -0
  43. package/src/helpers/XlsxTransformerColumnHelper.ts +47 -1
  44. package/src/seeds/1783097277-update-member-last-registered.sql +6 -0
  45. package/src/seeds/1783939632-mark-zero-price-payments-succeeded.ts +90 -0
  46. package/src/seeds/1784057557-fix-invisible-trial-registrations.ts +143 -0
  47. package/src/services/BalanceItemService.ts +1 -1
  48. package/src/services/PaymentService.ts +33 -8
  49. package/src/services/PlatformMembershipService.ts +2 -1
  50. package/src/services/RegistrationService.ts +5 -0
  51. package/src/services/uitpas/UitpasService.ts +3 -4
  52. package/src/sql-filters/members.ts +10 -0
  53. package/src/sql-filters/orders.ts +5 -0
  54. package/src/sql-sorters/members.ts +12 -1
  55. package/vitest.config.js +1 -0
@@ -1,7 +1,8 @@
1
1
  import { Request } from '@simonbackx/simple-endpoints';
2
2
  import type { Organization, RegistrationPeriod, User } from '@stamhoofd/models';
3
3
  import { Email, EmailRecipient, MemberFactory, OrganizationFactory, RegistrationPeriodFactory, Token, UserFactory } from '@stamhoofd/models';
4
- import { EmailStatus, LimitedFilteredRequest, Replacement } from '@stamhoofd/structures';
4
+ import { EmailContent, EmailStatus, LimitedFilteredRequest, Replacement } from '@stamhoofd/structures';
5
+ import { Language } from '@stamhoofd/types/Language';
5
6
  import { TestUtils } from '@stamhoofd/test-utils';
6
7
  import { testServer } from '../../../../tests/helpers/TestServer.js';
7
8
  import { GetUserEmailsEndpoint } from './GetUserEmailsEndpoint.js';
@@ -753,4 +754,133 @@ describe('Endpoint.GetUserEmails', () => {
753
754
 
754
755
  await expect(testServer.test(endpoint, request)).rejects.toThrow();
755
756
  });
757
+
758
+ describe('Language of returned emails', () => {
759
+ // The web greeting translations looked up in shared/locales/dist/locales/digit/{nl,fr}-BE.json,
760
+ // hardcoded on purpose so we don't verify $t with the same $t machinery we're testing
761
+ const dutchGreeting = 'Beste,';
762
+ const frenchGreeting = 'Bonjour,';
763
+
764
+ /**
765
+ * A sent email with a Dutch default content and a French translation.
766
+ * The {{greeting}} in the html proves the replacements match the displayed content.
767
+ */
768
+ const createTranslatedEmail = async (options: { recipientLanguage?: Language | null } = {}) => {
769
+ const email = new Email();
770
+ email.subject = 'Nederlands onderwerp';
771
+ email.status = EmailStatus.Sent;
772
+ email.text = 'Nederlandse tekst';
773
+ email.html = '<p>{{greeting}} Nederlandse inhoud</p>';
774
+ email.json = {};
775
+ email.language = Language.Dutch;
776
+ email.translations = new Map([
777
+ [Language.French, EmailContent.create({
778
+ subject: 'Sujet français',
779
+ html: '<p>{{greeting}} Contenu français</p>',
780
+ text: 'Texte français',
781
+ })],
782
+ ]);
783
+ email.organizationId = organization.id;
784
+ email.showInMemberPortal = true;
785
+ email.sentAt = new Date();
786
+ await email.save();
787
+
788
+ const emailRecipient = new EmailRecipient();
789
+ emailRecipient.emailId = email.id;
790
+ emailRecipient.memberId = member.id;
791
+ emailRecipient.userId = user.id;
792
+ emailRecipient.email = user.email;
793
+ emailRecipient.firstName = member.details.firstName;
794
+ emailRecipient.lastName = member.details.lastName;
795
+ emailRecipient.language = options.recipientLanguage ?? null;
796
+ emailRecipient.sentAt = new Date();
797
+ await emailRecipient.save();
798
+
799
+ return email;
800
+ };
801
+
802
+ const getUserEmailsViewedIn = async (language: string | null) => {
803
+ const request = Request.get({
804
+ path: baseUrl,
805
+ host: organization.getApiHost(),
806
+ query: new LimitedFilteredRequest({ limit: 10 }),
807
+ headers: {
808
+ 'authorization': 'Bearer ' + userToken.accessToken,
809
+ ...(language ? { 'accept-language': language } : {}),
810
+ },
811
+ });
812
+ const response = await testServer.test(endpoint, request);
813
+ expect(response.body.results).toHaveLength(1);
814
+ const result = response.body.results[0];
815
+ expect(result.recipients).toHaveLength(1);
816
+ return { result, recipient: result.recipients[0] };
817
+ };
818
+
819
+ test('the email is returned in the language the caller is viewing when a translation exists', async () => {
820
+ TestUtils.setEnvironment('locales', { BE: [Language.Dutch, Language.French] });
821
+ // The recipient received the email in Dutch, but views the portal in French
822
+ await createTranslatedEmail({ recipientLanguage: Language.Dutch });
823
+
824
+ const { result, recipient } = await getUserEmailsViewedIn('fr');
825
+
826
+ // The recipient language switches to the viewing language, so subject, html, text
827
+ // and the regenerated replacements all resolve to the French content
828
+ expect(recipient.language).toBe(Language.French);
829
+ expect(recipient.replacements.find(r => r.token === 'greeting')?.value).toBe(frenchGreeting);
830
+ expect(result.getSubjectFor(recipient)).toBe('Sujet français');
831
+ expect(result.getHtmlFor(recipient)).toBe(`<p>${frenchGreeting} Contenu français</p>`);
832
+ expect(result.getContentForLanguage(recipient.language).text).toBe('Texte français');
833
+ });
834
+
835
+ test('the language the email was received in is kept when there is no translation for the viewing language', async () => {
836
+ TestUtils.setEnvironment('locales', { BE: [Language.Dutch, Language.French] });
837
+ // The recipient received the email in French, but views the portal in English
838
+ await createTranslatedEmail({ recipientLanguage: Language.French });
839
+
840
+ const { result, recipient } = await getUserEmailsViewedIn('en');
841
+
842
+ expect(recipient.language).toBe(Language.French);
843
+ expect(recipient.replacements.find(r => r.token === 'greeting')?.value).toBe(frenchGreeting);
844
+ expect(result.getSubjectFor(recipient)).toBe('Sujet français');
845
+ expect(result.getHtmlFor(recipient)).toBe(`<p>${frenchGreeting} Contenu français</p>`);
846
+ expect(result.getContentForLanguage(recipient.language).text).toBe('Texte français');
847
+ });
848
+
849
+ test('the language the email was received in is kept when the caller has no explicit language', async () => {
850
+ TestUtils.setEnvironment('locales', { BE: [Language.Dutch, Language.French] });
851
+ await createTranslatedEmail({ recipientLanguage: Language.French });
852
+
853
+ const { result, recipient } = await getUserEmailsViewedIn(null);
854
+
855
+ expect(recipient.language).toBe(Language.French);
856
+ expect(recipient.replacements.find(r => r.token === 'greeting')?.value).toBe(frenchGreeting);
857
+ expect(result.getSubjectFor(recipient)).toBe('Sujet français');
858
+ expect(result.getHtmlFor(recipient)).toBe(`<p>${frenchGreeting} Contenu français</p>`);
859
+ });
860
+
861
+ test('a recipient without a stored language is returned in the viewing language when a translation exists', async () => {
862
+ TestUtils.setEnvironment('locales', { BE: [Language.Dutch, Language.French] });
863
+ await createTranslatedEmail({ recipientLanguage: null });
864
+
865
+ const { result, recipient } = await getUserEmailsViewedIn('fr');
866
+
867
+ expect(recipient.language).toBe(Language.French);
868
+ expect(recipient.replacements.find(r => r.token === 'greeting')?.value).toBe(frenchGreeting);
869
+ expect(result.getSubjectFor(recipient)).toBe('Sujet français');
870
+ expect(result.getHtmlFor(recipient)).toBe(`<p>${frenchGreeting} Contenu français</p>`);
871
+ });
872
+
873
+ test('a recipient without a stored language keeps the default content when the viewing language has no translation', async () => {
874
+ TestUtils.setEnvironment('locales', { BE: [Language.Dutch, Language.French] });
875
+ await createTranslatedEmail({ recipientLanguage: null });
876
+
877
+ const { result, recipient } = await getUserEmailsViewedIn('en');
878
+
879
+ expect(recipient.language).toBeNull();
880
+ expect(recipient.replacements.find(r => r.token === 'greeting')?.value).toBe(dutchGreeting);
881
+ expect(result.getSubjectFor(recipient)).toBe('Nederlands onderwerp');
882
+ expect(result.getHtmlFor(recipient)).toBe(`<p>${dutchGreeting} Nederlandse inhoud</p>`);
883
+ expect(result.getContentForLanguage(recipient.language).text).toBe('Nederlandse tekst');
884
+ });
885
+ });
756
886
  });
@@ -5,7 +5,9 @@ import { assertSort, EmailStatus, getSortFilter, LimitedFilteredRequest, mergeFi
5
5
 
6
6
  import type { Decoder } from '@simonbackx/simple-encoding';
7
7
  import { SimpleError } from '@simonbackx/simple-errors';
8
+ import { I18n } from '@stamhoofd/backend-i18n/I18n';
8
9
  import { Email, Member } from '@stamhoofd/models';
10
+ import type { Language } from '@stamhoofd/types/Language';
9
11
  import type { SQLFilterDefinitions, SQLSortDefinitions } from '@stamhoofd/sql';
10
12
  import { applySQLSorter, compileToSQLFilter } from '@stamhoofd/sql';
11
13
  import { Context } from '../../../helpers/Context.js';
@@ -103,7 +105,7 @@ export class GetUserEmailsEndpoint extends Endpoint<Params, Query, Body, Respons
103
105
  return query;
104
106
  }
105
107
 
106
- static async buildData(requestQuery: LimitedFilteredRequest) {
108
+ static async buildData(requestQuery: LimitedFilteredRequest, options: { language?: Language | null } = {}) {
107
109
  const user = Context.user;
108
110
  if (!user) {
109
111
  throw new Error('Not authenticated');
@@ -133,7 +135,7 @@ export class GetUserEmailsEndpoint extends Endpoint<Params, Query, Body, Respons
133
135
  }
134
136
 
135
137
  return new PaginatedResponse<EmailWithRecipients[], LimitedFilteredRequest>({
136
- results: await Promise.all(emails.map(email => email.getStructureForUser(user, memberIds))),
138
+ results: await Promise.all(emails.map(email => email.getStructureForUser(user, memberIds, options))),
137
139
  next,
138
140
  });
139
141
  }
@@ -161,7 +163,10 @@ export class GetUserEmailsEndpoint extends Endpoint<Params, Query, Body, Respons
161
163
  }
162
164
 
163
165
  return new Response(
164
- await GetUserEmailsEndpoint.buildData(request.query),
166
+ await GetUserEmailsEndpoint.buildData(request.query, {
167
+ // The language the caller is viewing in: emails are returned in this language when possible
168
+ language: I18n.getLanguageFromRequest(request),
169
+ }),
165
170
  );
166
171
  }
167
172
  }
@@ -1,8 +1,10 @@
1
1
  import type { AutoEncoderPatchType } from '@simonbackx/simple-encoding';
2
+ import { PatchMap } from '@simonbackx/simple-encoding';
2
3
  import { Request } from '@simonbackx/simple-endpoints';
3
4
  import type { Organization, RegistrationPeriod, User } from '@stamhoofd/models';
4
5
  import { Email, EmailRecipient, GroupFactory, MemberFactory, OrganizationFactory, RegistrationFactory, RegistrationPeriodFactory, Token, UserFactory } from '@stamhoofd/models';
5
- import { AccessRight, EmailRecipientFilter, EmailRecipientSubfilter, EmailStatus, Email as EmailStruct, OrganizationEmail, Parent, PermissionLevel, Permissions, PermissionsResourceType, ResourcePermissions, UserPermissions, Version } from '@stamhoofd/structures';
6
+ import { AccessRight, EmailContent, EmailRecipientFilter, EmailRecipientSubfilter, EmailStatus, Email as EmailStruct, OrganizationEmail, Parent, PermissionLevel, Permissions, PermissionsResourceType, ResourcePermissions, UserPermissions, Version } from '@stamhoofd/structures';
7
+ import { Language } from '@stamhoofd/types/Language';
6
8
  import { STExpect, TestUtils } from '@stamhoofd/test-utils';
7
9
  import { testServer } from '../../../../tests/helpers/TestServer.js';
8
10
  import { PatchEmailEndpoint } from './PatchEmailEndpoint.js';
@@ -1199,4 +1201,349 @@ describe('Endpoint.PatchEmailEndpoint', () => {
1199
1201
  expect(sentEmail.html).toContain('Identical Email');
1200
1202
  }
1201
1203
  });
1204
+
1205
+ describe('Translations', () => {
1206
+ const createDraftEmail = async (options: { language?: Language } = {}) => {
1207
+ const email = new Email();
1208
+ email.subject = 'Default subject';
1209
+ email.status = EmailStatus.Draft;
1210
+ email.text = 'Default text';
1211
+ email.html = '<p>Default html</p>';
1212
+ email.json = {};
1213
+ email.language = options.language ?? null;
1214
+ email.userId = user.id;
1215
+ email.organizationId = organization.id;
1216
+ email.senderId = sender.id;
1217
+ await email.save();
1218
+ return email;
1219
+ };
1220
+
1221
+ test('the first language can be set without creating a translation', async () => {
1222
+ const email = await createDraftEmail();
1223
+
1224
+ const body = EmailStruct.patch({
1225
+ id: email.id,
1226
+ language: Language.Dutch,
1227
+ });
1228
+
1229
+ const response = await patchEmail(body, token, organization);
1230
+ expect(response.body.language).toBe(Language.Dutch);
1231
+ expect(response.body.translations.size).toBe(0);
1232
+
1233
+ const saved = await Email.getByID(email.id);
1234
+ expect(saved!.language).toBe(Language.Dutch);
1235
+ expect(saved!.translations.size).toBe(0);
1236
+ expect(saved!.subject).toBe('Default subject');
1237
+ });
1238
+
1239
+ test('a translation can be added to a draft email', async () => {
1240
+ const email = await createDraftEmail({ language: Language.Dutch });
1241
+
1242
+ const body = EmailStruct.patch({
1243
+ id: email.id,
1244
+ translations: new PatchMap([[Language.French, EmailContent.create({ subject: 'Sujet français', html: '<p>Français</p>', text: 'Français' })]]),
1245
+ });
1246
+
1247
+ const response = await patchEmail(body, token, organization);
1248
+ expect(response.body.translations.get(Language.French)!.subject).toBe('Sujet français');
1249
+
1250
+ const saved = await Email.getByID(email.id);
1251
+ expect(saved!.translations.get(Language.French)!.subject).toBe('Sujet français');
1252
+ expect(saved!.subject).toBe('Default subject');
1253
+ expect(saved!.language).toBe(Language.Dutch);
1254
+ });
1255
+
1256
+ test('a translation can be removed from a draft email', async () => {
1257
+ const email = await createDraftEmail({ language: Language.Dutch });
1258
+ email.translations = new Map([
1259
+ [Language.French, EmailContent.create({ subject: 'Sujet français' })],
1260
+ [Language.English, EmailContent.create({ subject: 'English subject' })],
1261
+ ]);
1262
+ await email.save();
1263
+
1264
+ const body = EmailStruct.patch({
1265
+ id: email.id,
1266
+ translations: new PatchMap([[Language.French, null]]),
1267
+ });
1268
+
1269
+ await patchEmail(body, token, organization);
1270
+
1271
+ const saved = await Email.getByID(email.id);
1272
+ expect(saved!.translations.size).toBe(1);
1273
+ expect(saved!.translations.get(Language.English)!.subject).toBe('English subject');
1274
+ expect(saved!.language).toBe(Language.Dutch);
1275
+ });
1276
+
1277
+ test('removing the default language applies the move-over patch atomically', async () => {
1278
+ const email = await createDraftEmail({ language: Language.Dutch });
1279
+ email.translations = new Map([
1280
+ [Language.French, EmailContent.create({ subject: 'Sujet français', html: '<p>Français</p>', text: 'Français' })],
1281
+ ]);
1282
+ await email.save();
1283
+
1284
+ // The UI removes the default language by moving the first remaining translation
1285
+ // into the default content in a single patch
1286
+ const body = EmailStruct.patch({
1287
+ id: email.id,
1288
+ language: Language.French,
1289
+ subject: 'Sujet français',
1290
+ html: '<p>Français</p>',
1291
+ text: 'Français',
1292
+ translations: new PatchMap([[Language.French, null]]),
1293
+ });
1294
+
1295
+ await patchEmail(body, token, organization);
1296
+
1297
+ const saved = await Email.getByID(email.id);
1298
+ expect(saved!.language).toBe(Language.French);
1299
+ expect(saved!.subject).toBe('Sujet français');
1300
+ expect(saved!.html).toBe('<p>Français</p>');
1301
+ expect(saved!.translations.size).toBe(0);
1302
+ });
1303
+
1304
+ test('removing the last language keeps the content', async () => {
1305
+ const email = await createDraftEmail({ language: Language.Dutch });
1306
+
1307
+ const body = EmailStruct.patch({
1308
+ id: email.id,
1309
+ language: null,
1310
+ });
1311
+
1312
+ await patchEmail(body, token, organization);
1313
+
1314
+ const saved = await Email.getByID(email.id);
1315
+ expect(saved!.language).toBeNull();
1316
+ expect(saved!.subject).toBe('Default subject');
1317
+ expect(saved!.html).toBe('<p>Default html</p>');
1318
+ });
1319
+
1320
+ test('patching the subject keeps the translations', async () => {
1321
+ const email = await createDraftEmail({ language: Language.Dutch });
1322
+ email.translations = new Map([
1323
+ [Language.French, EmailContent.create({ subject: 'Sujet français' })],
1324
+ ]);
1325
+ await email.save();
1326
+
1327
+ const body = EmailStruct.patch({
1328
+ id: email.id,
1329
+ subject: 'New default subject',
1330
+ });
1331
+
1332
+ await patchEmail(body, token, organization);
1333
+
1334
+ const saved = await Email.getByID(email.id);
1335
+ expect(saved!.subject).toBe('New default subject');
1336
+ expect(saved!.language).toBe(Language.Dutch);
1337
+ expect(saved!.translations.get(Language.French)!.subject).toBe('Sujet français');
1338
+ });
1339
+
1340
+ test('rejects a translation without a default language', async () => {
1341
+ const email = await createDraftEmail();
1342
+
1343
+ const body = EmailStruct.patch({
1344
+ id: email.id,
1345
+ translations: new PatchMap([[Language.French, EmailContent.create({ subject: 'Sujet français' })]]),
1346
+ });
1347
+
1348
+ await expect(patchEmail(body, token, organization))
1349
+ .rejects
1350
+ .toThrow(STExpect.errorWithCode('invalid_translations'));
1351
+ });
1352
+
1353
+ test('rejects a translation for the default language itself', async () => {
1354
+ const email = await createDraftEmail({ language: Language.Dutch });
1355
+
1356
+ const body = EmailStruct.patch({
1357
+ id: email.id,
1358
+ translations: new PatchMap([[Language.Dutch, EmailContent.create({ subject: 'Nederlands onderwerp' })]]),
1359
+ });
1360
+
1361
+ await expect(patchEmail(body, token, organization))
1362
+ .rejects
1363
+ .toThrow(STExpect.errorWithCode('invalid_translations'));
1364
+ });
1365
+
1366
+ test('rejects clearing the default language while translations remain', async () => {
1367
+ const email = await createDraftEmail({ language: Language.Dutch });
1368
+ email.translations = new Map([
1369
+ [Language.French, EmailContent.create({ subject: 'Sujet français' })],
1370
+ ]);
1371
+ await email.save();
1372
+
1373
+ const body = EmailStruct.patch({
1374
+ id: email.id,
1375
+ language: null,
1376
+ });
1377
+
1378
+ await expect(patchEmail(body, token, organization))
1379
+ .rejects
1380
+ .toThrow(STExpect.errorWithCode('invalid_translations'));
1381
+ });
1382
+
1383
+ const createSendableEmail = async () => {
1384
+ const email = new Email();
1385
+ email.subject = 'Default subject';
1386
+ email.status = EmailStatus.Draft;
1387
+ email.text = 'Default text {{unsubscribeUrl}}';
1388
+ email.html = '<!DOCTYPE html><html><body><p>Default html</p>{{unsubscribeUrl}}</body></html>';
1389
+ email.json = {};
1390
+ email.language = Language.Dutch;
1391
+ email.userId = user.id;
1392
+ email.organizationId = organization.id;
1393
+ email.senderId = sender.id;
1394
+ return email;
1395
+ };
1396
+
1397
+ test('cannot send an email with an incomplete translation', async () => {
1398
+ const email = await createSendableEmail();
1399
+ email.translations = new Map([
1400
+ [Language.French, EmailContent.create({ subject: 'Sujet français', text: '', html: '' })],
1401
+ ]);
1402
+ await email.save();
1403
+
1404
+ const body = EmailStruct.patch({ id: email.id, status: EmailStatus.Sending });
1405
+
1406
+ await expect(patchEmail(body, token, organization))
1407
+ .rejects
1408
+ .toThrow(STExpect.errorWithCode('invalid_field'));
1409
+ });
1410
+
1411
+ test('cannot send an email when a translation misses the unsubscribe button', async () => {
1412
+ const email = await createSendableEmail();
1413
+ email.translations = new Map([
1414
+ [Language.French, EmailContent.create({
1415
+ subject: 'Sujet français',
1416
+ text: 'Texte français',
1417
+ html: '<!DOCTYPE html><html><body><p>Français</p></body></html>',
1418
+ })],
1419
+ ]);
1420
+ await email.save();
1421
+
1422
+ const body = EmailStruct.patch({ id: email.id, status: EmailStatus.Sending });
1423
+
1424
+ await expect(patchEmail(body, token, organization))
1425
+ .rejects
1426
+ .toThrow(STExpect.errorWithCode('missing_unsubscribe_button'));
1427
+ });
1428
+ });
1429
+
1430
+ describe('Example recipients per language', () => {
1431
+ // The greeting translations looked up in shared/locales/dist/locales/digit/{nl,fr}-BE.json,
1432
+ // hardcoded on purpose so we don't verify $t with the same $t machinery we're testing
1433
+ const dutchGreeting = 'Dag';
1434
+ const frenchGreeting = 'Bonjour';
1435
+
1436
+ const createDraftEmail = async (options: { language?: Language; translations?: Map<Language, EmailContent> } = {}) => {
1437
+ const email = new Email();
1438
+ email.subject = 'Default subject';
1439
+ email.status = EmailStatus.Draft;
1440
+ email.text = 'Default text';
1441
+ email.html = '<p>Default html</p>';
1442
+ email.json = {};
1443
+ email.language = options.language ?? null;
1444
+ email.translations = options.translations ?? new Map();
1445
+ email.userId = user.id;
1446
+ email.organizationId = organization.id;
1447
+ email.senderId = sender.id;
1448
+ await email.save();
1449
+ return email;
1450
+ };
1451
+
1452
+ const greetingOf = (recipient: { replacements: { token: string; value: string }[] }) => {
1453
+ return recipient.replacements.find(r => r.token === 'greeting')?.value;
1454
+ };
1455
+
1456
+ test('the same example recipient is returned with replacements in every supported language', async () => {
1457
+ TestUtils.setEnvironment('locales', { BE: [Language.Dutch, Language.French] });
1458
+ const email = await createDraftEmail();
1459
+
1460
+ const response = await patchEmail(EmailStruct.patch({ id: email.id }), token, organization);
1461
+ const preview = response.body;
1462
+
1463
+ expect([...preview.exampleRecipients.keys()].sort()).toEqual([Language.Dutch, Language.French].sort());
1464
+
1465
+ const dutch = preview.exampleRecipients.get(Language.Dutch)!;
1466
+ const french = preview.exampleRecipients.get(Language.French)!;
1467
+ expect(dutch.language).toBe(Language.Dutch);
1468
+ expect(french.language).toBe(Language.French);
1469
+
1470
+ // The same recipient in every language: only the replacements differ
1471
+ expect(french.firstName).toBe(dutch.firstName);
1472
+ expect(french.lastName).toBe(dutch.lastName);
1473
+ expect(french.email).toBe(dutch.email);
1474
+
1475
+ expect(greetingOf(dutch)).toBe(`${dutchGreeting} ${dutch.firstName},`);
1476
+ expect(greetingOf(french)).toBe(`${frenchGreeting} ${french.firstName},`);
1477
+
1478
+ // The deprecated single example recipient is still returned
1479
+ expect(preview.exampleRecipient).not.toBeNull();
1480
+ expect(greetingOf(preview.exampleRecipient!)).toBe(`${dutchGreeting} ${dutch.firstName},`);
1481
+ });
1482
+
1483
+ test('every language the email has content for is included when it is supported', async () => {
1484
+ TestUtils.setEnvironment('locales', { BE: [Language.Dutch, Language.French, Language.English] });
1485
+ const email = await createDraftEmail({
1486
+ language: Language.Dutch,
1487
+ translations: new Map([[Language.English, EmailContent.create({ subject: 'English subject', html: '<p>English</p>', text: 'English' })]]),
1488
+ });
1489
+
1490
+ const response = await patchEmail(EmailStruct.patch({ id: email.id }), token, organization);
1491
+ const preview = response.body;
1492
+
1493
+ expect([...preview.exampleRecipients.keys()].sort()).toEqual([Language.Dutch, Language.English, Language.French].sort());
1494
+ const english = preview.exampleRecipients.get(Language.English)!;
1495
+ expect(english.language).toBe(Language.English);
1496
+
1497
+ // The exact English text is machine-generated and can change between locale builds:
1498
+ // assert it was generated in its own language (not in one of the other languages)
1499
+ expect(greetingOf(english)).toBeDefined();
1500
+ expect(greetingOf(english)).not.toBe(greetingOf(preview.exampleRecipients.get(Language.Dutch)!));
1501
+ expect(greetingOf(english)).not.toBe(greetingOf(preview.exampleRecipients.get(Language.French)!));
1502
+ });
1503
+
1504
+ test('a translation in a language the platform no longer supports gets no example recipient', async () => {
1505
+ // Generating it anyway would silently produce values in the corrected (supported)
1506
+ // language, which is more misleading than the frontend falling back
1507
+ TestUtils.setEnvironment('locales', { BE: [Language.Dutch, Language.French] });
1508
+ const email = await createDraftEmail({
1509
+ language: Language.Dutch,
1510
+ translations: new Map([[Language.English, EmailContent.create({ subject: 'English subject', html: '<p>English</p>', text: 'English' })]]),
1511
+ });
1512
+
1513
+ const response = await patchEmail(EmailStruct.patch({ id: email.id }), token, organization);
1514
+ const preview = response.body;
1515
+
1516
+ expect([...preview.exampleRecipients.keys()].sort()).toEqual([Language.Dutch, Language.French].sort());
1517
+ });
1518
+
1519
+ test('the own language of a real example recipient is ignored', async () => {
1520
+ TestUtils.setEnvironment('locales', { BE: [Language.Dutch, Language.French] });
1521
+ const email = await createDraftEmail();
1522
+
1523
+ // A real recipient that received (or will receive) the email in French
1524
+ const recipient = new EmailRecipient();
1525
+ recipient.emailId = email.id;
1526
+ recipient.email = 'jean@example.com';
1527
+ recipient.firstName = 'Jean';
1528
+ recipient.lastName = 'Dupont';
1529
+ recipient.language = Language.French;
1530
+ recipient.organizationId = organization.id;
1531
+ await recipient.save();
1532
+
1533
+ const response = await patchEmail(EmailStruct.patch({ id: email.id }), token, organization);
1534
+ const preview = response.body;
1535
+
1536
+ // The per-language examples ignore the recipient's own language
1537
+ const dutch = preview.exampleRecipients.get(Language.Dutch)!;
1538
+ const french = preview.exampleRecipients.get(Language.French)!;
1539
+ expect(dutch.firstName).toBe('Jean');
1540
+ expect(dutch.language).toBe(Language.Dutch);
1541
+ expect(greetingOf(dutch)).toBe(`${dutchGreeting} Jean,`);
1542
+ expect(greetingOf(french)).toBe(`${frenchGreeting} Jean,`);
1543
+
1544
+ // The deprecated single example recipient still uses its own language
1545
+ expect(preview.exampleRecipient!.language).toBe(Language.French);
1546
+ expect(greetingOf(preview.exampleRecipient!)).toBe(`${frenchGreeting} Jean,`);
1547
+ });
1548
+ });
1202
1549
  });
@@ -2,7 +2,7 @@ import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
2
2
  import { Endpoint, Response } from '@simonbackx/simple-endpoints';
3
3
  import { Email, Platform } from '@stamhoofd/models';
4
4
  import type { EmailPreview } from '@stamhoofd/structures';
5
- import { EmailRecipientsStatus, EmailStatus, Email as EmailStruct, PermissionLevel } from '@stamhoofd/structures';
5
+ import { EmailRecipientsStatus, EmailStatus, Email as EmailStruct, PermissionLevel, validateEmailTranslations } from '@stamhoofd/structures';
6
6
 
7
7
  import type { AutoEncoderPatchType, Decoder } from '@simonbackx/simple-encoding';
8
8
  import { patchObject } from '@simonbackx/simple-encoding';
@@ -114,6 +114,25 @@ export class PatchEmailEndpoint extends Endpoint<Params, Query, Body, ResponseBo
114
114
  model.json = request.body.json;
115
115
  }
116
116
 
117
+ if (request.body.translations !== undefined) {
118
+ const lang = [...model.translations.keys()];
119
+ model.translations = patchObject(model.translations, request.body.translations);
120
+ const after = [...model.translations.keys()];
121
+
122
+ if (JSON.stringify(lang.sort()) && JSON.stringify(after.sort())) {
123
+ rebuild = true;
124
+ }
125
+ }
126
+
127
+ if (request.body.language !== undefined) {
128
+ model.language = request.body.language;
129
+ rebuild = true;
130
+ }
131
+
132
+ if (request.body.translations !== undefined || request.body.language !== undefined) {
133
+ validateEmailTranslations(model);
134
+ }
135
+
117
136
  if (request.body.recipientFilter) {
118
137
  if (model.status !== EmailStatus.Draft) {
119
138
  throw new SimpleError({
@@ -192,6 +211,6 @@ export class PatchEmailEndpoint extends Endpoint<Params, Query, Body, ResponseBo
192
211
  await model.queueForSending();
193
212
  }
194
213
 
195
- return new Response(await model.getPreviewStructure());
214
+ return new Response(await model.getPreviewStructure({ allLanguages: true }));
196
215
  }
197
216
  }