@stamhoofd/backend 2.135.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 (34) 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/global/email/CreateEmailEndpoint.ts +5 -2
  9. package/src/endpoints/global/email/GetAdminEmailsEndpoint.ts +1 -1
  10. package/src/endpoints/global/email/GetEmailEndpoint.ts +1 -1
  11. package/src/endpoints/global/email/GetUserEmailsEndpoint.test.ts +131 -1
  12. package/src/endpoints/global/email/GetUserEmailsEndpoint.ts +8 -3
  13. package/src/endpoints/global/email/PatchEmailEndpoint.test.ts +348 -1
  14. package/src/endpoints/global/email/PatchEmailEndpoint.ts +21 -2
  15. package/src/endpoints/global/members/GetMembersEndpoint.test.ts +119 -1
  16. package/src/endpoints/organization/dashboard/email-templates/PatchEmailTemplatesEndpoint.test.ts +271 -3
  17. package/src/endpoints/organization/dashboard/email-templates/PatchEmailTemplatesEndpoint.ts +15 -2
  18. package/src/endpoints/organization/dashboard/webshops/GetWebshopOrdersEndpoint.test.ts +112 -0
  19. package/src/endpoints/organization/webshops/OrderConfirmationEmailLanguage.test.ts +87 -1
  20. package/src/excel-loaders/members.test.ts +59 -0
  21. package/src/excel-loaders/members.ts +17 -0
  22. package/src/helpers/AuthenticatedStructures.ts +9 -0
  23. package/src/helpers/MemberMerger.test.ts +70 -2
  24. package/src/helpers/MemberMerger.ts +43 -1
  25. package/src/helpers/MemberUserSyncer.ts +5 -0
  26. package/src/helpers/MembershipCharger.test.ts +110 -0
  27. package/src/helpers/MembershipCharger.ts +10 -32
  28. package/src/helpers/XlsxTransformerColumnHelper.test.ts +63 -0
  29. package/src/helpers/XlsxTransformerColumnHelper.ts +47 -1
  30. package/src/seeds/1784057557-fix-invisible-trial-registrations.ts +143 -0
  31. package/src/services/BalanceItemService.ts +1 -1
  32. package/src/sql-filters/members.ts +5 -0
  33. package/src/sql-filters/orders.ts +5 -0
  34. package/vitest.config.js +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stamhoofd/backend",
3
- "version": "2.135.0",
3
+ "version": "2.136.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "exports": {
@@ -40,6 +40,7 @@
40
40
  "lint": "eslint",
41
41
  "migrations": "yarn -s build:full && node ./dist/migrations.js",
42
42
  "start": "yarn -s build && node --enable-source-maps ./dist/index.js",
43
+ "typecheck": "tsc --build tsconfig.test.json",
43
44
  "test": "vitest",
44
45
  "test:coverage": "vitest --coverage",
45
46
  "test:reset": "yarn -s build:full && yarn -s test"
@@ -57,20 +58,20 @@
57
58
  "@simonbackx/simple-endpoints": "1.21.1",
58
59
  "@simonbackx/simple-errors": "1.5.0",
59
60
  "@simonbackx/simple-logging": "1.0.1",
60
- "@stamhoofd/backend-env": "2.135.0",
61
- "@stamhoofd/backend-i18n": "2.135.0",
62
- "@stamhoofd/backend-middleware": "2.135.0",
63
- "@stamhoofd/crons": "2.135.0",
64
- "@stamhoofd/email": "2.135.0",
65
- "@stamhoofd/excel-writer": "2.135.0",
66
- "@stamhoofd/logging": "2.135.0",
67
- "@stamhoofd/models": "2.135.0",
68
- "@stamhoofd/object-differ": "2.135.0",
69
- "@stamhoofd/queues": "2.135.0",
70
- "@stamhoofd/sql": "2.135.0",
71
- "@stamhoofd/structures": "2.135.0",
72
- "@stamhoofd/types": "2.135.0",
73
- "@stamhoofd/utility": "2.135.0",
61
+ "@stamhoofd/backend-env": "2.136.0",
62
+ "@stamhoofd/backend-i18n": "2.136.0",
63
+ "@stamhoofd/backend-middleware": "2.136.0",
64
+ "@stamhoofd/crons": "2.136.0",
65
+ "@stamhoofd/email": "2.136.0",
66
+ "@stamhoofd/excel-writer": "2.136.0",
67
+ "@stamhoofd/logging": "2.136.0",
68
+ "@stamhoofd/models": "2.136.0",
69
+ "@stamhoofd/object-differ": "2.136.0",
70
+ "@stamhoofd/queues": "2.136.0",
71
+ "@stamhoofd/sql": "2.136.0",
72
+ "@stamhoofd/structures": "2.136.0",
73
+ "@stamhoofd/types": "2.136.0",
74
+ "@stamhoofd/utility": "2.136.0",
74
75
  "archiver": "7.0.1",
75
76
  "axios": "1.16.0",
76
77
  "base-x": "3.0.11",
@@ -91,7 +92,7 @@
91
92
  "stripe": "16.12.0"
92
93
  },
93
94
  "devDependencies": {
94
- "@stamhoofd/test-utils": "2.135.0",
95
+ "@stamhoofd/test-utils": "2.136.0",
95
96
  "@types/cookie": "0.6.0",
96
97
  "@types/luxon": "3.7.1",
97
98
  "@types/mailparser": "3.4.6",
@@ -107,5 +108,5 @@
107
108
  "publishConfig": {
108
109
  "access": "public"
109
110
  },
110
- "gitHead": "df20ecb0e4b10af877d2e37e92e39100afe342e8"
111
+ "gitHead": "0adab3f0a2aac8470c58ac717c3d96a824e5f935"
111
112
  }
@@ -0,0 +1,88 @@
1
+ import { BalanceItemFactory, CachedBalance, MemberFactory, OrganizationFactory, UserFactory } from '@stamhoofd/models';
2
+ import { ReceivableBalanceType } from '@stamhoofd/structures';
3
+ import { TestUtils } from '@stamhoofd/test-utils';
4
+ import { v4 as uuidv4 } from 'uuid';
5
+
6
+ describe('cron.cleanup-orphaned-cached-balances', () => {
7
+ beforeEach(() => {
8
+ TestUtils.setEnvironment('userMode', 'platform');
9
+ });
10
+
11
+ test('deletes cached balances without any balance item and keeps the ones that still have balance items', async () => {
12
+ const organization = await new OrganizationFactory({}).create();
13
+ const memberWithItem = await new MemberFactory({ organization }).create();
14
+ const orphanMember = await new MemberFactory({ organization }).create();
15
+
16
+ await new BalanceItemFactory({
17
+ organizationId: organization.id,
18
+ memberId: memberWithItem.id,
19
+ amount: 1,
20
+ unitPrice: 25_00,
21
+ }).create();
22
+
23
+ // Seed cached balances for both members. The orphan member gets a (zero) row even though
24
+ // it has no balance items.
25
+ await CachedBalance.updateForMembers(organization.id, [memberWithItem.id, orphanMember.id]);
26
+
27
+ // A cached balance of a registration that no longer has balance items
28
+ const orphanRegistration = new CachedBalance();
29
+ orphanRegistration.organizationId = organization.id;
30
+ orphanRegistration.objectId = uuidv4();
31
+ orphanRegistration.objectType = ReceivableBalanceType.registration;
32
+ await orphanRegistration.save();
33
+
34
+ // A cached balance of a paying organization that no longer has balance items
35
+ const orphanOrganization = new CachedBalance();
36
+ orphanOrganization.organizationId = organization.id;
37
+ orphanOrganization.objectId = uuidv4();
38
+ orphanOrganization.objectType = ReceivableBalanceType.organization;
39
+ await orphanOrganization.save();
40
+
41
+ // Sanity check: all rows exist before cleanup
42
+ expect(await CachedBalance.getForObjects([orphanMember.id], organization.id, ReceivableBalanceType.member)).toHaveLength(1);
43
+
44
+ await CachedBalance.deleteOrphaned();
45
+
46
+ // The member with a balance item is kept
47
+ const remainingMembers = await CachedBalance.getForObjects(
48
+ [memberWithItem.id, orphanMember.id],
49
+ organization.id,
50
+ ReceivableBalanceType.member,
51
+ );
52
+ expect(remainingMembers.map(b => b.objectId)).toEqual([memberWithItem.id]);
53
+
54
+ // The orphaned registration and organization cached balances are deleted
55
+ expect(await CachedBalance.getByID(orphanRegistration.id)).toBeUndefined();
56
+ expect(await CachedBalance.getByID(orphanOrganization.id)).toBeUndefined();
57
+ });
58
+
59
+ test('keeps a user cached balance when a linked member still has balance items, but removes the user-without-members row', async () => {
60
+ const organization = await new OrganizationFactory({}).create();
61
+ const user = await new UserFactory({ organization }).create();
62
+ const member = await new MemberFactory({ organization, user }).create();
63
+
64
+ // The balance item belongs to the member, not directly to the user
65
+ await new BalanceItemFactory({
66
+ organizationId: organization.id,
67
+ memberId: member.id,
68
+ amount: 1,
69
+ unitPrice: 40_00,
70
+ }).create();
71
+
72
+ await CachedBalance.updateForMembers(organization.id, [member.id]);
73
+ await CachedBalance.updateForUsers(organization.id, [user.id]);
74
+
75
+ // Both a 'user' and a 'userWithoutMembers' row are created for the user
76
+ const before = await CachedBalance.getForObjects([user.id], organization.id);
77
+ expect(before.map(b => b.objectType).sort()).toEqual(
78
+ [ReceivableBalanceType.user, ReceivableBalanceType.userWithoutMembers].sort(),
79
+ );
80
+
81
+ await CachedBalance.deleteOrphaned();
82
+
83
+ // The 'user' row is kept (the linked member still has balance items), the
84
+ // 'userWithoutMembers' row is deleted (the user has no direct balance items)
85
+ const after = await CachedBalance.getForObjects([user.id], organization.id);
86
+ expect(after.map(b => b.objectType)).toEqual([ReceivableBalanceType.user]);
87
+ });
88
+ });
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Cached balances are only recalculated for objects that still have balance items. When all the
3
+ * balance items of an object are moved or removed (for example when a member is merged into
4
+ * another member), its cached balance is left behind. This cron cleans up those orphaned rows.
5
+ */
6
+
7
+ import { registerCron } from '@stamhoofd/crons';
8
+ import { CachedBalance } from '@stamhoofd/models';
9
+
10
+ let lastRunDate: number | null = null;
11
+
12
+ registerCron('cleanupOrphanedCachedBalances', cleanupOrphanedCachedBalances);
13
+
14
+ function shouldRun() {
15
+ const now = new Date();
16
+
17
+ if (now.getDate() === lastRunDate) {
18
+ return false;
19
+ }
20
+
21
+ const hour = now.getHours();
22
+
23
+ // between 3 and 4 AM - except in development
24
+ if (hour !== 3 && STAMHOOFD.environment !== 'development') {
25
+ return false;
26
+ }
27
+
28
+ return true;
29
+ }
30
+
31
+ /**
32
+ * Delete cached balances that no longer have any balance item attached to them.
33
+ * Runs once a day between 3 and 4 AM.
34
+ */
35
+ async function cleanupOrphanedCachedBalances() {
36
+ if (!shouldRun()) {
37
+ return;
38
+ }
39
+
40
+ lastRunDate = new Date().getDate();
41
+
42
+ const deleted = await CachedBalance.deleteOrphaned();
43
+ console.log(`Deleted ${deleted} orphaned cached balances.`);
44
+ }
@@ -2,6 +2,7 @@ import './amazon-ses.js';
2
2
  import './clearExcelCache.js';
3
3
  import './endFunctionsOfUsersWithoutRegistration.js';
4
4
  import './update-cached-balances.js';
5
+ import './cleanup-orphaned-cached-balances.js';
5
6
  import './balance-emails.js';
6
7
  import './delete-old-email-drafts.js';
7
8
  import './delete-archived-data.js';
@@ -1,4 +1,4 @@
1
- import { Email, Webshop } from '@stamhoofd/models';
1
+ import { Email, runWithRecipientLocale, Webshop } from '@stamhoofd/models';
2
2
  import type { EmailRecipient, LimitedFilteredRequest, WebshopPreview } from '@stamhoofd/structures';
3
3
  import { mergeFilters, PaginatedResponse } from '@stamhoofd/structures';
4
4
  import { GetWebshopOrdersEndpoint } from '../endpoints/organization/dashboard/webshops/GetWebshopOrdersEndpoint.js';
@@ -7,14 +7,12 @@ import { Context } from '../helpers/Context.js';
7
7
  import { EmailRecipientFilterType } from '@stamhoofd/structures/email/EmailRecipientFilterType.js';
8
8
 
9
9
  Email.recipientLoaders.set(EmailRecipientFilterType.Orders, {
10
- fetch: async (query: LimitedFilteredRequest) => {
11
- // #region get organization struct
10
+ fetch: async (query: LimitedFilteredRequest, _subfilter, _beforeFetchAllResult, options) => {
12
11
  const organization = Context.organization;
13
12
  if (organization === undefined) {
14
13
  throw new Error('Organization is undefined');
15
14
  }
16
15
  const organizationStruct = organization.getBaseStructure();
17
- // #endregion
18
16
 
19
17
  const result = await GetWebshopOrdersEndpoint.buildData(query);
20
18
 
@@ -32,17 +30,17 @@ Email.recipientLoaders.set(EmailRecipientFilterType.Orders, {
32
30
 
33
31
  webshopPreviewMap.set(webshopId, AuthenticatedStructures.webshopPreview(webshop));
34
32
  }
35
- // #endregion
36
33
 
37
- // #region get recipients
38
34
  const recipients: EmailRecipient[] = [];
39
35
 
40
36
  for (const order of result.results) {
41
- // todo: filter double emails? => should probably happen in query?
42
37
  const webshopPreview = webshopPreviewMap.get(order.webshopId)!;
43
- recipients.push(order.getEmailRecipient(organizationStruct, webshopPreview));
38
+ runWithRecipientLocale({ language: order.consumerLanguage }, organization, (i18n) => {
39
+ const r = order.getEmailRecipient(organizationStruct, webshopPreview);
40
+ r.language = i18n.language;
41
+ recipients.push(r);
42
+ }, options);
44
43
  }
45
- // #endregion
46
44
 
47
45
  return new PaginatedResponse({
48
46
  results: recipients,
@@ -0,0 +1,95 @@
1
+ import { Request } from '@simonbackx/simple-endpoints';
2
+ import type { Member, Organization, RegistrationPeriod, Token } from '@stamhoofd/models';
3
+ import { MemberFactory, MemberPlatformMembership, OrganizationFactory, Platform, RegistrationPeriodFactory } from '@stamhoofd/models';
4
+ import { TestUtils } from '@stamhoofd/test-utils';
5
+ import { v4 as uuidv4 } from 'uuid';
6
+ import { testServer } from '../../../../tests/helpers/TestServer.js';
7
+ import { initPlatformAdmin } from '../../../../tests/init/index.js';
8
+ import { GetChargeMembershipsSummaryEndpoint } from './GetChargeMembershipsSummaryEndpoint.js';
9
+
10
+ describe('Endpoint.GetChargeMembershipsSummary', () => {
11
+ const endpoint = new GetChargeMembershipsSummaryEndpoint();
12
+ const membershipPrice = 25_00;
13
+
14
+ let currentPeriod: RegistrationPeriod;
15
+ let nextPeriod: RegistrationPeriod;
16
+ let membershipOrganization: Organization;
17
+ let payingOrganization: Organization;
18
+
19
+ const getSummary = async (token: Token) => {
20
+ const request = Request.get({
21
+ path: '/admin/charge-memberships/summary',
22
+ // Platform admins are not scoped to an organization, so no host is required
23
+ host: '',
24
+ headers: {
25
+ authorization: 'Bearer ' + token.accessToken,
26
+ },
27
+ });
28
+ const response = await testServer.test(endpoint, request);
29
+ return response.body;
30
+ };
31
+
32
+ // Creates an uncharged (not yet invoiced) membership that should show up in the summary
33
+ const createUnchargedMembership = async (period: RegistrationPeriod, organization: Organization, member: Member) => {
34
+ const membership = new MemberPlatformMembership();
35
+ membership.memberId = member.id;
36
+ membership.membershipTypeId = uuidv4();
37
+ membership.organizationId = organization.id;
38
+ membership.periodId = period.id;
39
+ membership.startDate = period.startDate;
40
+ membership.endDate = period.endDate;
41
+ membership.price = membershipPrice;
42
+ await membership.save();
43
+ return membership;
44
+ };
45
+
46
+ beforeEach(async () => {
47
+ TestUtils.setEnvironment('userMode', 'platform');
48
+ });
49
+
50
+ beforeAll(async () => {
51
+ TestUtils.setEnvironment('userMode', 'platform');
52
+
53
+ currentPeriod = await new RegistrationPeriodFactory({
54
+ startDate: new Date(2024, 0, 1, 0, 0, 0, 0),
55
+ endDate: new Date(2024, 11, 31, 23, 59, 59, 0),
56
+ }).create();
57
+
58
+ nextPeriod = await new RegistrationPeriodFactory({
59
+ startDate: new Date(2025, 0, 1, 0, 0, 0, 0),
60
+ endDate: new Date(2025, 11, 31, 23, 59, 59, 0),
61
+ }).create();
62
+
63
+ membershipOrganization = await new OrganizationFactory({}).create();
64
+ payingOrganization = await new OrganizationFactory({}).create();
65
+
66
+ const platform = await Platform.getForEditing();
67
+ platform.periodId = currentPeriod.id;
68
+ platform.nextPeriodId = nextPeriod.id;
69
+ platform.membershipOrganizationId = membershipOrganization.id;
70
+ await platform.save();
71
+ });
72
+
73
+ test('Excludes next period memberships from the charge summary total', async () => {
74
+ const { adminToken } = await initPlatformAdmin();
75
+
76
+ // Use deltas relative to a baseline, so the test is robust to memberships created elsewhere
77
+ const before = await getSummary(adminToken);
78
+
79
+ // A membership in the next period should not be counted
80
+ const nextMember = await new MemberFactory({}).create();
81
+ await createUnchargedMembership(nextPeriod, payingOrganization, nextMember);
82
+
83
+ const afterNextPeriod = await getSummary(adminToken);
84
+ expect(afterNextPeriod.memberships).toBe(before.memberships);
85
+ expect(afterNextPeriod.price).toBe(before.price);
86
+
87
+ // A membership in the current period should be counted
88
+ const currentMember = await new MemberFactory({}).create();
89
+ await createUnchargedMembership(currentPeriod, payingOrganization, currentMember);
90
+
91
+ const afterCurrentPeriod = await getSummary(adminToken);
92
+ expect(afterCurrentPeriod.memberships).toBe(before.memberships + 1);
93
+ expect(afterCurrentPeriod.price).toBe(before.price + membershipPrice);
94
+ });
95
+ });
@@ -111,6 +111,13 @@ export class GetChargeMembershipsSummaryEndpoint extends Endpoint<Params, Query,
111
111
  query.whereNot(noTrial);
112
112
  }
113
113
 
114
+ const platform = await Platform.getShared();
115
+ const nextPeriodId = platform.nextPeriodId;
116
+
117
+ if (nextPeriodId) {
118
+ query.where('periodId', '!=', nextPeriodId);
119
+ }
120
+
114
121
  const result = await query.fetch();
115
122
  const members = result[0]['data']['members'] as number;
116
123
  const memberships = result[0]['data']['memberships'] as number;
@@ -3,7 +3,7 @@ import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
3
3
  import { Endpoint, Response } from '@simonbackx/simple-endpoints';
4
4
  import { Email, Platform, RateLimiter } from '@stamhoofd/models';
5
5
  import type { EmailPreview } from '@stamhoofd/structures';
6
- import { EmailStatus, Email as EmailStruct, EmailTemplate as EmailTemplateStruct } from '@stamhoofd/structures';
6
+ import { EmailStatus, Email as EmailStruct, EmailTemplate as EmailTemplateStruct, validateEmailTranslations } from '@stamhoofd/structures';
7
7
 
8
8
  import { Context } from '../../../helpers/Context.js';
9
9
  import { SimpleError } from '@simonbackx/simple-errors';
@@ -83,6 +83,9 @@ export class CreateEmailEndpoint extends Endpoint<Params, Query, Body, ResponseB
83
83
  model.html = request.body.html;
84
84
  model.text = request.body.text;
85
85
  model.json = request.body.json;
86
+ model.translations = request.body.translations;
87
+ model.language = request.body.language;
88
+ validateEmailTranslations(model);
86
89
  model.status = request.body.status;
87
90
  model.attachments = request.body.attachments;
88
91
  model.sendAsEmail = request.body.sendAsEmail ?? true;
@@ -159,6 +162,6 @@ export class CreateEmailEndpoint extends Endpoint<Params, Query, Body, ResponseB
159
162
  await duplicate.save();
160
163
  }
161
164
 
162
- return new Response(await model.getPreviewStructure());
165
+ return new Response(await model.getPreviewStructure({ allLanguages: true }));
163
166
  }
164
167
  }
@@ -158,7 +158,7 @@ export class GetAdminEmailsEndpoint extends Endpoint<Params, Query, Body, Respon
158
158
  }
159
159
 
160
160
  return new PaginatedResponse<EmailPreview[], LimitedFilteredRequest>({
161
- results: await Promise.all(emails.map(email => email.getPreviewStructure())),
161
+ results: await Promise.all(emails.map(email => email.getPreviewStructure({ allLanguages: true }))),
162
162
  next,
163
163
  });
164
164
  }
@@ -51,6 +51,6 @@ export class GetEmailEndpoint extends Endpoint<Params, Query, Body, ResponseBody
51
51
  throw Context.auth.error();
52
52
  }
53
53
 
54
- return new Response(await model.getPreviewStructure());
54
+ return new Response(await model.getPreviewStructure({ allLanguages: true }));
55
55
  }
56
56
  }
@@ -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
  }