@stamhoofd/backend 2.135.0 → 2.136.1
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/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 +287 -1
- package/src/endpoints/global/email/PatchEmailEndpoint.ts +21 -2
- package/src/endpoints/global/members/GetMembersEndpoint.test.ts +119 -1
- 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/webshops/GetWebshopOrdersEndpoint.test.ts +112 -0
- package/src/endpoints/organization/webshops/OrderConfirmationEmailLanguage.test.ts +87 -1
- 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/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 +10 -32
- package/src/helpers/XlsxTransformerColumnHelper.test.ts +63 -0
- package/src/helpers/XlsxTransformerColumnHelper.ts +47 -1
- package/src/seeds/1784057557-fix-invisible-trial-registrations.ts +143 -0
- package/src/services/BalanceItemService.ts +1 -1
- package/src/sql-filters/members.ts +5 -0
- package/src/sql-filters/orders.ts +5 -0
- package/vitest.config.js +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stamhoofd/backend",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.136.1",
|
|
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.
|
|
61
|
-
"@stamhoofd/backend-i18n": "2.
|
|
62
|
-
"@stamhoofd/backend-middleware": "2.
|
|
63
|
-
"@stamhoofd/crons": "2.
|
|
64
|
-
"@stamhoofd/email": "2.
|
|
65
|
-
"@stamhoofd/excel-writer": "2.
|
|
66
|
-
"@stamhoofd/logging": "2.
|
|
67
|
-
"@stamhoofd/models": "2.
|
|
68
|
-
"@stamhoofd/object-differ": "2.
|
|
69
|
-
"@stamhoofd/queues": "2.
|
|
70
|
-
"@stamhoofd/sql": "2.
|
|
71
|
-
"@stamhoofd/structures": "2.
|
|
72
|
-
"@stamhoofd/types": "2.
|
|
73
|
-
"@stamhoofd/utility": "2.
|
|
61
|
+
"@stamhoofd/backend-env": "2.136.1",
|
|
62
|
+
"@stamhoofd/backend-i18n": "2.136.1",
|
|
63
|
+
"@stamhoofd/backend-middleware": "2.136.1",
|
|
64
|
+
"@stamhoofd/crons": "2.136.1",
|
|
65
|
+
"@stamhoofd/email": "2.136.1",
|
|
66
|
+
"@stamhoofd/excel-writer": "2.136.1",
|
|
67
|
+
"@stamhoofd/logging": "2.136.1",
|
|
68
|
+
"@stamhoofd/models": "2.136.1",
|
|
69
|
+
"@stamhoofd/object-differ": "2.136.1",
|
|
70
|
+
"@stamhoofd/queues": "2.136.1",
|
|
71
|
+
"@stamhoofd/sql": "2.136.1",
|
|
72
|
+
"@stamhoofd/structures": "2.136.1",
|
|
73
|
+
"@stamhoofd/types": "2.136.1",
|
|
74
|
+
"@stamhoofd/utility": "2.136.1",
|
|
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.
|
|
95
|
+
"@stamhoofd/test-utils": "2.136.1",
|
|
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": "
|
|
111
|
+
"gitHead": "8c3886185fd30008018414c73f88aed6c0cb5185"
|
|
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
|
+
}
|
package/src/crons/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|