@stamhoofd/backend 2.134.0 → 2.136.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/package.json +18 -17
  2. package/src/crons/cleanup-orphaned-cached-balances.test.ts +88 -0
  3. package/src/crons/cleanup-orphaned-cached-balances.ts +44 -0
  4. package/src/crons/index.ts +1 -0
  5. package/src/email-recipient-loaders/orders.ts +7 -9
  6. package/src/endpoints/admin/memberships/GetChargeMembershipsSummaryEndpoint.test.ts +95 -0
  7. package/src/endpoints/admin/memberships/GetChargeMembershipsSummaryEndpoint.ts +7 -0
  8. package/src/endpoints/auth/VerifyEmailEndpoint.test.ts +154 -0
  9. package/src/endpoints/auth/VerifyEmailEndpoint.ts +4 -0
  10. package/src/endpoints/global/email/CreateEmailEndpoint.ts +5 -2
  11. package/src/endpoints/global/email/GetAdminEmailsEndpoint.ts +1 -1
  12. package/src/endpoints/global/email/GetEmailEndpoint.ts +1 -1
  13. package/src/endpoints/global/email/GetUserEmailsEndpoint.test.ts +131 -1
  14. package/src/endpoints/global/email/GetUserEmailsEndpoint.ts +8 -3
  15. package/src/endpoints/global/email/PatchEmailEndpoint.test.ts +348 -1
  16. package/src/endpoints/global/email/PatchEmailEndpoint.ts +21 -2
  17. package/src/endpoints/global/members/GetMembersEndpoint.test.ts +342 -2
  18. package/src/endpoints/global/registration/RegisterMembersEndpoint.test.ts +150 -0
  19. package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +4 -3
  20. package/src/endpoints/organization/dashboard/email-templates/PatchEmailTemplatesEndpoint.test.ts +271 -3
  21. package/src/endpoints/organization/dashboard/email-templates/PatchEmailTemplatesEndpoint.ts +15 -2
  22. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +2 -0
  23. package/src/endpoints/organization/dashboard/organization/SetUitpasClientCredentialsEndpoint.ts +1 -8
  24. package/src/endpoints/organization/dashboard/webshops/GetWebshopOrdersEndpoint.test.ts +112 -0
  25. package/src/endpoints/organization/dashboard/webshops/PatchWebshopEndpoint.test.ts +49 -0
  26. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.test.ts +71 -0
  27. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +4 -0
  28. package/src/endpoints/organization/webshops/GetWebshopEndpoint.test.ts +11 -0
  29. package/src/endpoints/organization/webshops/OrderConfirmationEmailLanguage.test.ts +156 -0
  30. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.test.ts +68 -1
  31. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +8 -0
  32. package/src/excel-loaders/members.test.ts +59 -0
  33. package/src/excel-loaders/members.ts +17 -0
  34. package/src/helpers/AuthenticatedStructures.ts +9 -0
  35. package/src/helpers/GlobalHelper.ts +1 -1
  36. package/src/helpers/MemberMerger.test.ts +70 -2
  37. package/src/helpers/MemberMerger.ts +43 -1
  38. package/src/helpers/MemberUserSyncer.ts +5 -0
  39. package/src/helpers/MembershipCharger.test.ts +110 -0
  40. package/src/helpers/MembershipCharger.ts +12 -33
  41. package/src/helpers/UitpasTokenRepository.ts +12 -11
  42. package/src/helpers/XlsxTransformerColumnHelper.test.ts +63 -0
  43. package/src/helpers/XlsxTransformerColumnHelper.ts +47 -1
  44. package/src/seeds/1783097277-update-member-last-registered.sql +6 -0
  45. package/src/seeds/1783939632-mark-zero-price-payments-succeeded.ts +90 -0
  46. package/src/seeds/1784057557-fix-invisible-trial-registrations.ts +143 -0
  47. package/src/services/BalanceItemService.ts +1 -1
  48. package/src/services/PaymentService.ts +33 -8
  49. package/src/services/PlatformMembershipService.ts +2 -1
  50. package/src/services/RegistrationService.ts +5 -0
  51. package/src/services/uitpas/UitpasService.ts +3 -4
  52. package/src/sql-filters/members.ts +10 -0
  53. package/src/sql-filters/orders.ts +5 -0
  54. package/src/sql-sorters/members.ts +12 -1
  55. package/vitest.config.js +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stamhoofd/backend",
3
- "version": "2.134.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.134.0",
61
- "@stamhoofd/backend-i18n": "2.134.0",
62
- "@stamhoofd/backend-middleware": "2.134.0",
63
- "@stamhoofd/crons": "2.134.0",
64
- "@stamhoofd/email": "2.134.0",
65
- "@stamhoofd/excel-writer": "2.134.0",
66
- "@stamhoofd/logging": "2.134.0",
67
- "@stamhoofd/models": "2.134.0",
68
- "@stamhoofd/object-differ": "2.134.0",
69
- "@stamhoofd/queues": "2.134.0",
70
- "@stamhoofd/sql": "2.134.0",
71
- "@stamhoofd/structures": "2.134.0",
72
- "@stamhoofd/types": "2.134.0",
73
- "@stamhoofd/utility": "2.134.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.134.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": "42955365bcf0bcfd0815998c1039e23d6ee1d6c6"
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;
@@ -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
  }