@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.
- package/package.json +18 -17
- package/src/crons/cleanup-orphaned-cached-balances.test.ts +88 -0
- package/src/crons/cleanup-orphaned-cached-balances.ts +44 -0
- package/src/crons/index.ts +1 -0
- package/src/email-recipient-loaders/orders.ts +7 -9
- package/src/endpoints/admin/memberships/GetChargeMembershipsSummaryEndpoint.test.ts +95 -0
- package/src/endpoints/admin/memberships/GetChargeMembershipsSummaryEndpoint.ts +7 -0
- package/src/endpoints/auth/VerifyEmailEndpoint.test.ts +154 -0
- package/src/endpoints/auth/VerifyEmailEndpoint.ts +4 -0
- package/src/endpoints/global/email/CreateEmailEndpoint.ts +5 -2
- package/src/endpoints/global/email/GetAdminEmailsEndpoint.ts +1 -1
- package/src/endpoints/global/email/GetEmailEndpoint.ts +1 -1
- package/src/endpoints/global/email/GetUserEmailsEndpoint.test.ts +131 -1
- package/src/endpoints/global/email/GetUserEmailsEndpoint.ts +8 -3
- package/src/endpoints/global/email/PatchEmailEndpoint.test.ts +348 -1
- package/src/endpoints/global/email/PatchEmailEndpoint.ts +21 -2
- package/src/endpoints/global/members/GetMembersEndpoint.test.ts +342 -2
- package/src/endpoints/global/registration/RegisterMembersEndpoint.test.ts +150 -0
- package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +4 -3
- package/src/endpoints/organization/dashboard/email-templates/PatchEmailTemplatesEndpoint.test.ts +271 -3
- package/src/endpoints/organization/dashboard/email-templates/PatchEmailTemplatesEndpoint.ts +15 -2
- package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +2 -0
- package/src/endpoints/organization/dashboard/organization/SetUitpasClientCredentialsEndpoint.ts +1 -8
- package/src/endpoints/organization/dashboard/webshops/GetWebshopOrdersEndpoint.test.ts +112 -0
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopEndpoint.test.ts +49 -0
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.test.ts +71 -0
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +4 -0
- package/src/endpoints/organization/webshops/GetWebshopEndpoint.test.ts +11 -0
- package/src/endpoints/organization/webshops/OrderConfirmationEmailLanguage.test.ts +156 -0
- package/src/endpoints/organization/webshops/PlaceOrderEndpoint.test.ts +68 -1
- package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +8 -0
- package/src/excel-loaders/members.test.ts +59 -0
- package/src/excel-loaders/members.ts +17 -0
- package/src/helpers/AuthenticatedStructures.ts +9 -0
- package/src/helpers/GlobalHelper.ts +1 -1
- package/src/helpers/MemberMerger.test.ts +70 -2
- package/src/helpers/MemberMerger.ts +43 -1
- package/src/helpers/MemberUserSyncer.ts +5 -0
- package/src/helpers/MembershipCharger.test.ts +110 -0
- package/src/helpers/MembershipCharger.ts +12 -33
- package/src/helpers/UitpasTokenRepository.ts +12 -11
- package/src/helpers/XlsxTransformerColumnHelper.test.ts +63 -0
- package/src/helpers/XlsxTransformerColumnHelper.ts +47 -1
- package/src/seeds/1783097277-update-member-last-registered.sql +6 -0
- package/src/seeds/1783939632-mark-zero-price-payments-succeeded.ts +90 -0
- package/src/seeds/1784057557-fix-invisible-trial-registrations.ts +143 -0
- package/src/services/BalanceItemService.ts +1 -1
- package/src/services/PaymentService.ts +33 -8
- package/src/services/PlatformMembershipService.ts +2 -1
- package/src/services/RegistrationService.ts +5 -0
- package/src/services/uitpas/UitpasService.ts +3 -4
- package/src/sql-filters/members.ts +10 -0
- package/src/sql-filters/orders.ts +5 -0
- package/src/sql-sorters/members.ts +12 -1
- 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
|
}
|