@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stamhoofd/backend",
|
|
3
|
-
"version": "2.
|
|
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.
|
|
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.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.
|
|
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": "
|
|
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
|
+
}
|
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;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { Request } from '@simonbackx/simple-endpoints';
|
|
2
|
+
import { AuditLog, EmailVerificationCode, OrganizationFactory, Token, User, UserFactory } from '@stamhoofd/models';
|
|
3
|
+
import type { Organization } from '@stamhoofd/models';
|
|
4
|
+
import { AuditLogType, PermissionLevel, PermissionRole, Permissions, Token as TokenStruct } from '@stamhoofd/structures';
|
|
5
|
+
import { TestUtils } from '@stamhoofd/test-utils';
|
|
6
|
+
|
|
7
|
+
import { testServer } from '../../../tests/helpers/TestServer.js';
|
|
8
|
+
import { VerifyEmailEndpoint } from './VerifyEmailEndpoint.js';
|
|
9
|
+
|
|
10
|
+
describe('Endpoint.VerifyEmail', () => {
|
|
11
|
+
const endpoint = new VerifyEmailEndpoint();
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
TestUtils.setEnvironment('userMode', 'organization');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Create a user with permissions for the given organization.
|
|
19
|
+
*/
|
|
20
|
+
async function createUserWithPermissions(organization: Organization, email: string, permissions?: Permissions) {
|
|
21
|
+
return await new UserFactory({
|
|
22
|
+
organization,
|
|
23
|
+
email,
|
|
24
|
+
permissions,
|
|
25
|
+
}).create();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Build a verification code for the given user that will change the user's email to `newEmail`
|
|
30
|
+
* on verification, and run the endpoint with it.
|
|
31
|
+
*/
|
|
32
|
+
async function verifyEmail(organization: Organization, user: User, newEmail: string) {
|
|
33
|
+
const code = await EmailVerificationCode.createFor(user, newEmail);
|
|
34
|
+
|
|
35
|
+
const request = Request.buildJson('POST', '/verify-email', organization.getApiHost(), {
|
|
36
|
+
token: code.token,
|
|
37
|
+
code: code.code,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
return await testServer.test(endpoint, request);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
test('merges the permissions of the deleted user into the kept user', async () => {
|
|
44
|
+
const organization = await new OrganizationFactory({}).create();
|
|
45
|
+
|
|
46
|
+
// The user that keeps existing and changes its email to the other user's email
|
|
47
|
+
const keptUser = await createUserWithPermissions(organization, 'kept@example.com', Permissions.create({
|
|
48
|
+
roles: [PermissionRole.create({ id: 'role-a', name: 'Role A' })],
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
// The user that will be found by its email and merged into (and deleted)
|
|
52
|
+
const otherUser = await createUserWithPermissions(organization, 'other@example.com', Permissions.create({
|
|
53
|
+
level: PermissionLevel.Full,
|
|
54
|
+
roles: [PermissionRole.create({ id: 'role-b', name: 'Role B' })],
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
const response = await verifyEmail(organization, keptUser, otherUser.email);
|
|
58
|
+
expect(response.status).toBe(200);
|
|
59
|
+
|
|
60
|
+
// The other user is deleted
|
|
61
|
+
expect(await User.getByID(otherUser.id)).toBeUndefined();
|
|
62
|
+
|
|
63
|
+
// The kept user now holds both users' permissions for the organization
|
|
64
|
+
const refreshed = await User.getByID(keptUser.id);
|
|
65
|
+
expect(refreshed).toBeDefined();
|
|
66
|
+
expect(refreshed!.email).toBe('other@example.com');
|
|
67
|
+
|
|
68
|
+
const merged = refreshed!.permissions!.organizationPermissions.get(organization.id);
|
|
69
|
+
expect(merged).toBeDefined();
|
|
70
|
+
expect(merged!.level).toBe(PermissionLevel.Full);
|
|
71
|
+
expect(merged!.roles.map(r => r.id).sort()).toEqual(['role-a', 'role-b']);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('adopts the permissions of the deleted user when the kept user has none', async () => {
|
|
75
|
+
const organization = await new OrganizationFactory({}).create();
|
|
76
|
+
|
|
77
|
+
// The kept user has no permissions
|
|
78
|
+
const keptUser = await createUserWithPermissions(organization, 'kept@example.com');
|
|
79
|
+
expect(keptUser.permissions).toBeNull();
|
|
80
|
+
|
|
81
|
+
const otherUser = await createUserWithPermissions(organization, 'other@example.com', Permissions.create({
|
|
82
|
+
roles: [PermissionRole.create({ id: 'role-b', name: 'Role B' })],
|
|
83
|
+
}));
|
|
84
|
+
|
|
85
|
+
const response = await verifyEmail(organization, keptUser, otherUser.email);
|
|
86
|
+
expect(response.status).toBe(200);
|
|
87
|
+
|
|
88
|
+
expect(await User.getByID(otherUser.id)).toBeUndefined();
|
|
89
|
+
|
|
90
|
+
const refreshed = await User.getByID(keptUser.id);
|
|
91
|
+
const merged = refreshed!.permissions?.organizationPermissions.get(organization.id);
|
|
92
|
+
expect(merged).toBeDefined();
|
|
93
|
+
expect(merged!.roles.map(r => r.id)).toEqual(['role-b']);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('reassigns audit logs of the deleted user to the kept user', async () => {
|
|
97
|
+
const organization = await new OrganizationFactory({}).create();
|
|
98
|
+
|
|
99
|
+
const keptUser = await createUserWithPermissions(organization, 'kept@example.com');
|
|
100
|
+
const otherUser = await createUserWithPermissions(organization, 'other@example.com');
|
|
101
|
+
|
|
102
|
+
// An audit log performed by the user that will be deleted
|
|
103
|
+
const auditLog = new AuditLog();
|
|
104
|
+
auditLog.type = AuditLogType.Unknown;
|
|
105
|
+
auditLog.userId = otherUser.id;
|
|
106
|
+
auditLog.organizationId = organization.id;
|
|
107
|
+
auditLog.description = 'Performed by the other user';
|
|
108
|
+
await auditLog.save();
|
|
109
|
+
|
|
110
|
+
const response = await verifyEmail(organization, keptUser, otherUser.email);
|
|
111
|
+
expect(response.status).toBe(200);
|
|
112
|
+
|
|
113
|
+
expect(await User.getByID(otherUser.id)).toBeUndefined();
|
|
114
|
+
|
|
115
|
+
// The audit log is now attributed to the kept user
|
|
116
|
+
const refreshedLog = await AuditLog.getByID(auditLog.id);
|
|
117
|
+
expect(refreshedLog).toBeDefined();
|
|
118
|
+
expect(refreshedLog!.userId).toBe(keptUser.id);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('verifies the email and returns a valid token when merging without throwing', async () => {
|
|
122
|
+
const organization = await new OrganizationFactory({}).create();
|
|
123
|
+
|
|
124
|
+
const keptUser = await createUserWithPermissions(organization, 'kept@example.com', Permissions.create({
|
|
125
|
+
roles: [PermissionRole.create({ id: 'role-a', name: 'Role A' })],
|
|
126
|
+
}));
|
|
127
|
+
const otherUser = await createUserWithPermissions(organization, 'other@example.com', Permissions.create({
|
|
128
|
+
roles: [PermissionRole.create({ id: 'role-b', name: 'Role B' })],
|
|
129
|
+
}));
|
|
130
|
+
|
|
131
|
+
// Also add an audit log to exercise all merge branches together
|
|
132
|
+
const auditLog = new AuditLog();
|
|
133
|
+
auditLog.type = AuditLogType.Unknown;
|
|
134
|
+
auditLog.userId = otherUser.id;
|
|
135
|
+
auditLog.organizationId = organization.id;
|
|
136
|
+
await auditLog.save();
|
|
137
|
+
|
|
138
|
+
const response = await verifyEmail(organization, keptUser, otherUser.email);
|
|
139
|
+
|
|
140
|
+
expect(response.body).toBeInstanceOf(TokenStruct);
|
|
141
|
+
if (!(response.body instanceof TokenStruct)) {
|
|
142
|
+
throw new Error('Expected TokenStruct');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// The returned token belongs to the kept user
|
|
146
|
+
const token = await Token.getByAccessToken(response.body.accessToken);
|
|
147
|
+
expect(token).toBeDefined();
|
|
148
|
+
expect(token!.user.id).toBe(keptUser.id);
|
|
149
|
+
|
|
150
|
+
const refreshed = await User.getByID(keptUser.id);
|
|
151
|
+
expect(refreshed!.verified).toBe(true);
|
|
152
|
+
expect(refreshed!.email).toBe('other@example.com');
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -6,6 +6,7 @@ import { EmailVerificationCode, Token, User } from '@stamhoofd/models';
|
|
|
6
6
|
import { Token as TokenStruct, VerifyEmailRequest } from '@stamhoofd/structures';
|
|
7
7
|
|
|
8
8
|
import { Context } from '../../helpers/Context.js';
|
|
9
|
+
import { BalanceItemService } from '../../services/BalanceItemService.js';
|
|
9
10
|
|
|
10
11
|
type Params = Record<string, never>;
|
|
11
12
|
type Query = undefined;
|
|
@@ -77,6 +78,9 @@ export class VerifyEmailEndpoint extends Endpoint<Params, Query, Body, ResponseB
|
|
|
77
78
|
if (other) {
|
|
78
79
|
// Delete the other user, but merge data
|
|
79
80
|
await user.merge(other);
|
|
81
|
+
if (user.organizationId) {
|
|
82
|
+
BalanceItemService.scheduleUserUpdate(user.organizationId, user.id);
|
|
83
|
+
}
|
|
80
84
|
}
|
|
81
85
|
|
|
82
86
|
// change user email
|
|
@@ -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
|
}
|