@stamhoofd/backend 2.137.4 → 2.138.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 (142) hide show
  1. package/package.json +26 -17
  2. package/src/boot.ts +5 -0
  3. package/src/crons/balance-emails.ts +2 -1
  4. package/src/crons/delete-expired-mfa-tokens.ts +35 -0
  5. package/src/crons/drip-emails.ts +2 -1
  6. package/src/crons/index.ts +1 -0
  7. package/src/crons/invoices.ts +5 -3
  8. package/src/crons.ts +4 -2
  9. package/src/email-recipient-loaders/orders.ts +2 -1
  10. package/src/email-recipient-loaders/organizations.ts +3 -2
  11. package/src/email-recipient-loaders/payments.ts +3 -2
  12. package/src/endpoints/auth/ConfirmTOTPEndpoint.ts +84 -0
  13. package/src/endpoints/auth/CreateAdminEndpoint.ts +4 -2
  14. package/src/endpoints/auth/CreateTokenEndpoint.test.ts +61 -1
  15. package/src/endpoints/auth/CreateTokenEndpoint.ts +135 -7
  16. package/src/endpoints/auth/DeletePasskeyEndpoint.ts +61 -0
  17. package/src/endpoints/auth/DeleteTOTPEndpoint.ts +62 -0
  18. package/src/endpoints/auth/DeleteUserEndpoint.ts +1 -1
  19. package/src/endpoints/auth/ForgotPasswordEndpoint.ts +4 -2
  20. package/src/endpoints/auth/GetMFAChallengeEndpoint.ts +56 -0
  21. package/src/endpoints/auth/GetMFAStatusEndpoint.ts +31 -0
  22. package/src/endpoints/auth/GetUserEndpoint.test.ts +32 -1
  23. package/src/endpoints/auth/MFA.security.test.ts +688 -0
  24. package/src/endpoints/auth/MFA.test.ts +1398 -0
  25. package/src/endpoints/auth/OpenIDConnectAuthTokenEndpoint.ts +2 -2
  26. package/src/endpoints/auth/PatchUserEndpoint.ts +6 -3
  27. package/src/endpoints/auth/RegenerateRecoveryCodesEndpoint.ts +51 -0
  28. package/src/endpoints/auth/RegisterPasskeyEndpoint.ts +99 -0
  29. package/src/endpoints/auth/RegisterPasskeyOptionsEndpoint.ts +49 -0
  30. package/src/endpoints/auth/RetryEmailVerificationEndpoint.ts +2 -1
  31. package/src/endpoints/auth/SetupTOTPEndpoint.ts +54 -0
  32. package/src/endpoints/auth/SignupEndpoint.ts +17 -4
  33. package/src/endpoints/auth/VerifyEmailEndpoint.ts +16 -0
  34. package/src/endpoints/global/email/CreateEmailEndpoint.ts +5 -3
  35. package/src/endpoints/global/email/GetAdminEmailsEndpoint.test.ts +1 -1
  36. package/src/endpoints/global/email/GetAdminEmailsEndpoint.ts +2 -1
  37. package/src/endpoints/global/email/GetEmailEndpoint.ts +2 -1
  38. package/src/endpoints/global/email/GetUserEmailsEndpoint.test.ts +1 -1
  39. package/src/endpoints/global/email/GetUserEmailsEndpoint.ts +2 -1
  40. package/src/endpoints/global/email/PatchEmailEndpoint.ts +5 -3
  41. package/src/endpoints/global/email-recipients/GetEmailRecipientsEndpoint.ts +2 -1
  42. package/src/endpoints/global/email-recipients/RetryEmailRecipientEndpoint.ts +2 -1
  43. package/src/endpoints/global/files/ExportToExcelEndpoint.ts +11 -6
  44. package/src/endpoints/global/files/UploadFile.test.ts +206 -0
  45. package/src/endpoints/global/files/UploadFile.ts +31 -6
  46. package/src/endpoints/global/files/UploadImage.test.ts +177 -0
  47. package/src/endpoints/global/files/UploadImage.ts +23 -4
  48. package/src/endpoints/global/files/upload-security.test.ts +837 -0
  49. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +1 -1
  50. package/src/endpoints/global/members/SendMemberSecurityCodeEndpoint.ts +2 -1
  51. package/src/endpoints/global/organizations/CreateOrganizationEndpoint.ts +2 -1
  52. package/src/endpoints/global/platform/GetPlatformAdminsEndpoint.ts +4 -1
  53. package/src/endpoints/global/platform/PatchPlatformEnpoint.test.ts +35 -1
  54. package/src/endpoints/global/platform/PatchPlatformEnpoint.ts +8 -0
  55. package/src/endpoints/global/platform/SignOutPlatformAdminsEndpoint.test.ts +92 -0
  56. package/src/endpoints/global/platform/SignOutPlatformAdminsEndpoint.ts +43 -0
  57. package/src/endpoints/organization/dashboard/documents/GetDocumentTemplateXML.ts +2 -1
  58. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +1 -0
  59. package/src/endpoints/organization/dashboard/organization/SetOrganizationDomainEndpoint.ts +3 -2
  60. package/src/endpoints/organization/dashboard/receivable-balances/ChargeReceivableBalancesEndpoint.ts +3 -2
  61. package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.test.ts +32 -3
  62. package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.ts +4 -1
  63. package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.test.ts +100 -0
  64. package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.ts +4 -1
  65. package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.test.ts +5 -5
  66. package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.ts +5 -3
  67. package/src/endpoints/organization/dashboard/users/SignOutOrganizationAdminsEndpoint.test.ts +160 -0
  68. package/src/endpoints/organization/dashboard/users/SignOutOrganizationAdminsEndpoint.ts +44 -0
  69. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +4 -3
  70. package/src/endpoints/organization/shared/GetDocumentHtml.ts +2 -1
  71. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +4 -3
  72. package/src/excel-loaders/balance-items.ts +1 -1
  73. package/src/excel-loaders/event-notifications.ts +6 -7
  74. package/src/excel-loaders/members.test.ts +2 -2
  75. package/src/excel-loaders/members.ts +9 -10
  76. package/src/excel-loaders/organizations.ts +14 -20
  77. package/src/excel-loaders/payments.ts +1 -1
  78. package/src/excel-loaders/platform-memberships.ts +7 -7
  79. package/src/excel-loaders/platform-sheets.test.ts +113 -0
  80. package/src/excel-loaders/receivable-balances.ts +1 -1
  81. package/src/excel-loaders/registrations.ts +9 -9
  82. package/src/helpers/AdminPermissionChecker.ts +1 -1
  83. package/src/helpers/AuthenticatedStructures.ts +21 -10
  84. package/src/helpers/Context.ts +114 -4
  85. package/src/helpers/EmailBuilder.test.ts +287 -0
  86. package/src/helpers/EmailBuilder.ts +811 -0
  87. package/src/helpers/EmailResumer.ts +2 -1
  88. package/src/helpers/ForwardHandler.ts +2 -1
  89. package/src/helpers/MFAEncryption.ts +34 -0
  90. package/src/helpers/MembershipCharger.ts +3 -2
  91. package/src/helpers/RecoveryCodeHelper.ts +81 -0
  92. package/src/helpers/TOTPHelper.ts +79 -0
  93. package/src/helpers/TenantContext.test.ts +96 -0
  94. package/src/helpers/TenantContext.ts +67 -0
  95. package/src/helpers/TwoFactorHelper.ts +356 -0
  96. package/src/helpers/WebauthnHelper.ts +211 -0
  97. package/src/helpers/data/aaguids.json +1006 -0
  98. package/src/middleware/TenantScopeMiddleware.test.ts +41 -0
  99. package/src/middleware/TenantScopeMiddleware.ts +24 -0
  100. package/src/seeds/1785417302-fill-user-last-active-at.sql +6 -0
  101. package/src/services/AdminSessionService.ts +41 -0
  102. package/src/services/BalanceItemService.ts +2 -1
  103. package/src/services/DocumentRenderService.test.ts +229 -0
  104. package/src/services/DocumentRenderService.ts +180 -0
  105. package/src/services/EmailPreviewService.test.ts +300 -0
  106. package/src/services/EmailPreviewService.ts +239 -0
  107. package/src/services/EmailSendService.test.ts +1218 -0
  108. package/src/services/EmailSendService.ts +705 -0
  109. package/src/services/EventNotificationService.ts +2 -1
  110. package/src/services/FileSignService.test.ts +85 -0
  111. package/src/services/FileSignService.ts +23 -1
  112. package/src/services/InvoicePdfService.ts +3 -3
  113. package/src/services/InvoiceService.ts +4 -2
  114. package/src/services/InvoiceXMLService.ts +2 -1
  115. package/src/services/OrderService.test.ts +308 -0
  116. package/src/services/OrderService.ts +215 -0
  117. package/src/services/OrganizationAdminService.test.ts +47 -0
  118. package/src/services/OrganizationAdminService.ts +139 -0
  119. package/src/services/OrganizationDNSService.test.ts +177 -0
  120. package/src/services/OrganizationDNSService.ts +282 -0
  121. package/src/services/OrganizationEmailService.test.ts +57 -0
  122. package/src/services/OrganizationEmailService.ts +212 -0
  123. package/src/services/PasswordForgotService.test.ts +99 -0
  124. package/src/services/PasswordForgotService.ts +38 -0
  125. package/src/services/PaymentService.ts +5 -3
  126. package/src/services/PlatformMembershipService.test.ts +180 -0
  127. package/src/services/PlatformMembershipService.ts +281 -4
  128. package/src/services/ReferralService.ts +3 -2
  129. package/src/services/RegistrationService.ts +4 -2
  130. package/src/services/SSOService.ts +106 -14
  131. package/src/services/STPackageService.test.ts +191 -0
  132. package/src/services/STPackageService.ts +115 -1
  133. package/src/services/TwoFactorAuditLogService.ts +64 -0
  134. package/src/services/VerificationCodeService.test.ts +71 -0
  135. package/src/services/VerificationCodeService.ts +101 -0
  136. package/tests/e2e/api-rate-limits.test.ts +1 -1
  137. package/tests/e2e/documents.test.ts +2 -1
  138. package/tests/e2e/private-files.test.ts +77 -14
  139. package/tests/helpers/MFATestHelper.ts +42 -0
  140. package/tests/helpers/TestServer.ts +4 -0
  141. package/tests/helpers/index.ts +1 -0
  142. package/tsconfig.build.json +2 -1
@@ -0,0 +1,41 @@
1
+ import type { DecodedRequest } from '@simonbackx/simple-endpoints';
2
+ import { Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
3
+ import { ROOT_TENANT_ID } from '@stamhoofd/models';
4
+ import { testServer } from '../../tests/helpers/TestServer.js';
5
+ import { TenantContext } from '../helpers/TenantContext.js';
6
+
7
+ type Params = Record<string, never>;
8
+
9
+ /**
10
+ * Reports the tenant scope it was reached in, so a request can assert what the middleware set up.
11
+ */
12
+ class TenantProbeEndpoint extends Endpoint<Params, undefined, undefined, string> {
13
+ protected doesMatch(request: Request): [true, Params] | [false] {
14
+ if (request.method !== 'GET' || request.url !== '/tenant-probe') {
15
+ return [false];
16
+ }
17
+ return [true, {}];
18
+ }
19
+
20
+ async handle(_request: DecodedRequest<Params, undefined, undefined>) {
21
+ return new Response(TenantContext.optional?.tenantId ?? 'no-tenant');
22
+ }
23
+ }
24
+
25
+ describe('TenantScopeMiddleware', () => {
26
+ test('a request runs in a tenant scope', async () => {
27
+ const endpoint = new TenantProbeEndpoint();
28
+
29
+ const response = await testServer.test(endpoint, Request.buildJson('GET', '/tenant-probe', 'localhost'));
30
+
31
+ expect(response.body).toBe(ROOT_TENANT_ID);
32
+ });
33
+
34
+ test('the scope does not outlive the request', async () => {
35
+ const endpoint = new TenantProbeEndpoint();
36
+
37
+ await testServer.test(endpoint, Request.buildJson('GET', '/tenant-probe', 'localhost'));
38
+
39
+ expect(TenantContext.optional).toBeNull();
40
+ });
41
+ });
@@ -0,0 +1,24 @@
1
+ import type { Request, RequestMiddleware } from '@simonbackx/simple-endpoints';
2
+ import { ROOT_TENANT_ID } from '@stamhoofd/models';
3
+
4
+ import { TenantContext } from '../helpers/TenantContext.js';
5
+
6
+ /**
7
+ * Puts every request in a tenant scope.
8
+ *
9
+ * Every request resolves to the deployment's root tenant for now. Resolving it from the host is a
10
+ * later step; this exists so the scope is already there when it starts to matter.
11
+ *
12
+ * Register this *after* ContextMiddleware: wrapRun wrappers are applied in registration order, so
13
+ * the last one registered is the outermost. The tenant has to be in scope before the context that
14
+ * reads it.
15
+ */
16
+ export const TenantScopeMiddleware: RequestMiddleware = {
17
+ wrapRun<T>(run: () => Promise<T>, _request: Request) {
18
+ return TenantContext.run(ROOT_TENANT_ID, run);
19
+ },
20
+
21
+ handleRequest() {
22
+ // Noop
23
+ },
24
+ };
@@ -0,0 +1,6 @@
1
+ UPDATE `users` u
2
+ SET u.lastActiveAt = (
3
+ SELECT MAX(t.updatedAt)
4
+ FROM tokens t
5
+ WHERE t.userId = u.id
6
+ );
@@ -0,0 +1,41 @@
1
+ import { Token, User } from '@stamhoofd/models';
2
+
3
+ /**
4
+ * Signs out administrators in bulk.
5
+ *
6
+ * Making two-factor authentication required only affects new logins: it is evaluated when a
7
+ * session is created, so admins that are already signed in keep their session and never get
8
+ * asked to enroll. An administrator can therefore ask us to end those sessions, which forces
9
+ * every other admin through the login flow again — and with that through the forced
10
+ * enrollment.
11
+ *
12
+ * API users are never signed out: their tokens are machine credentials with a long lifetime
13
+ * that cannot complete an interactive login. User.getAdmins()/getPlatformAdmins() already
14
+ * leave them out.
15
+ */
16
+ export class AdminSessionService {
17
+ /**
18
+ * Sign out every administrator of an organization.
19
+ *
20
+ * `keepAccessToken` is the session of the administrator making the request: signing
21
+ * themselves out would interrupt what they are doing, and they are forced to enroll on
22
+ * their next login anyway.
23
+ *
24
+ * Returns the number of ended sessions.
25
+ */
26
+ static async signOutOrganizationAdmins(organizationId: string, keepAccessToken: string | null): Promise<number> {
27
+ const admins = await User.getAdmins(organizationId);
28
+ return await Token.deleteForUsers(admins.map(a => a.id), keepAccessToken);
29
+ }
30
+
31
+ /**
32
+ * Sign out every administrator of the platform. Administrators that only have
33
+ * permissions within an organization are not affected.
34
+ *
35
+ * Returns the number of ended sessions.
36
+ */
37
+ static async signOutPlatformAdmins(keepAccessToken: string | null): Promise<number> {
38
+ const admins = await User.getPlatformAdmins();
39
+ return await Token.deleteForUsers(admins.map(a => a.id), keepAccessToken);
40
+ }
41
+ }
@@ -7,6 +7,7 @@ import { Formatter } from '@stamhoofd/utility';
7
7
  import { GroupedThrottledQueue } from '../helpers/GroupedThrottledQueue.js';
8
8
  import { ThrottledQueue } from '../helpers/ThrottledQueue.js';
9
9
  import { AuditLogService } from './AuditLogService.js';
10
+ import { OrderService } from './OrderService.js';
10
11
  import { PaymentReallocationService } from './PaymentReallocationService.js';
11
12
  import { RegistrationService } from './RegistrationService.js';
12
13
  import { STPackageService } from './STPackageService.js';
@@ -235,7 +236,7 @@ export class BalanceItemService {
235
236
  const order = await Order.getByID(balanceItem.orderId);
236
237
  if (order) {
237
238
  shouldMarkUpdated = false;
238
- await order.markPaid(payment, organization);
239
+ await OrderService.markPaid(order, payment, organization);
239
240
 
240
241
  // Save number in balance description
241
242
  if (order.number !== null) {
@@ -0,0 +1,229 @@
1
+ import { SimpleError } from '@simonbackx/simple-errors';
2
+ import { Document, DocumentTemplateFactory, Organization } from '@stamhoofd/models';
3
+ import { Address, DocumentData, DocumentStatus, File, Image, OrganizationMetaData, PlatformConfig, Platform as PlatformStruct, RecordSettings, RecordTextAnswer, RecordType } from '@stamhoofd/structures';
4
+ import { Country } from '@stamhoofd/types/Country';
5
+ import { DocumentRenderService } from './DocumentRenderService.js';
6
+
7
+ function createImage(id: string) {
8
+ return Image.create({
9
+ id,
10
+ source: new File({
11
+ id,
12
+ server: 'https://files.example.com',
13
+ path: id + '.png',
14
+ size: 100,
15
+ }),
16
+ });
17
+ }
18
+
19
+ /**
20
+ * An organization that is not saved in the database: enough to build a context with.
21
+ */
22
+ function createOrganization(meta?: Partial<{ horizontalLogo: Image | null; squareLogo: Image | null }>) {
23
+ const organization = new Organization();
24
+ organization.name = 'Test organization';
25
+ organization.meta = OrganizationMetaData.create({
26
+ horizontalLogo: meta?.horizontalLogo ?? null,
27
+ squareLogo: meta?.squareLogo ?? null,
28
+ });
29
+ organization.address = Address.create({
30
+ street: 'Demostraat',
31
+ number: '12',
32
+ city: 'Gent',
33
+ postalCode: '9000',
34
+ country: Country.Belgium,
35
+ });
36
+ return organization;
37
+ }
38
+
39
+ function createDocument() {
40
+ const document = new Document();
41
+ document.data = DocumentData.create({ name: 'Test document' });
42
+ return document;
43
+ }
44
+
45
+ function createPlatform(config: Partial<{ logoDocuments: Image | null; horizontalLogo: Image | null; squareLogo: Image | null }>) {
46
+ return PlatformStruct.create({
47
+ config: PlatformConfig.create({
48
+ logoDocuments: config.logoDocuments ?? null,
49
+ horizontalLogo: config.horizontalLogo ?? null,
50
+ squareLogo: config.squareLogo ?? null,
51
+ }),
52
+ });
53
+ }
54
+
55
+ /**
56
+ * An answer that cannot be mapped onto the context object, so building the context throws.
57
+ */
58
+ function createInvalidFieldAnswers() {
59
+ return new Map([
60
+ ['', RecordTextAnswer.create({
61
+ settings: RecordSettings.create({ id: '', type: RecordType.Text }),
62
+ value: 'Some value',
63
+ })],
64
+ ]);
65
+ }
66
+
67
+ const xmlExport = '<documents>{{#each documents}}<document>{{{this.number}}}</document>{{/each}}</documents>';
68
+
69
+ /**
70
+ * Builds a locked template: a locked template makes buildAll return the documents unchanged, so the
71
+ * numbers set up here are exactly what the renumbering logic gets to see.
72
+ */
73
+ async function createTemplateWithDocuments(numbers: number[], options?: { xmlExport?: string | null; lockDocuments?: boolean }) {
74
+ const template = await new DocumentTemplateFactory({
75
+ groups: [],
76
+ status: DocumentStatus.Published,
77
+ }).createWithoutSave();
78
+
79
+ template.privateSettings.templateDefinition.xmlExport = options?.xmlExport === undefined ? xmlExport : options.xmlExport;
80
+ await template.save();
81
+
82
+ const documents: Document[] = [];
83
+ for (const number of numbers) {
84
+ const document = new Document();
85
+ document.organizationId = template.organizationId;
86
+ document.templateId = template.id;
87
+ document.status = DocumentStatus.Published;
88
+ document.number = number;
89
+ document.data = DocumentData.create({ name: 'Test document' });
90
+ await document.save();
91
+
92
+ if (options?.lockDocuments) {
93
+ document.isLocked = true;
94
+ await document.save({ forceSave: true });
95
+ }
96
+ documents.push(document);
97
+ }
98
+
99
+ // Lock last: a locked template can no longer be saved normally
100
+ template.isLocked = true;
101
+ await template.save({ forceSave: true });
102
+
103
+ const organization = (await Organization.getByID(template.organizationId))!;
104
+ return { template, organization, documents };
105
+ }
106
+
107
+ describe('DocumentRenderService', () => {
108
+ describe('buildDocumentContext', () => {
109
+ test('The platform logo falls back from logoDocuments to horizontalLogo to squareLogo', () => {
110
+ const logoDocuments = createImage('logo-documents');
111
+ const horizontalLogo = createImage('horizontal-logo');
112
+ const squareLogo = createImage('square-logo');
113
+
114
+ const organization = createOrganization();
115
+ const document = createDocument();
116
+
117
+ const all = DocumentRenderService.buildDocumentContext(document, organization, createPlatform({ logoDocuments, horizontalLogo, squareLogo }));
118
+ expect(all['platform'].logo.id).toBe(logoDocuments.id);
119
+ expect(all['logo'].id).toBe(logoDocuments.id);
120
+
121
+ const withoutDocumentsLogo = DocumentRenderService.buildDocumentContext(document, organization, createPlatform({ horizontalLogo, squareLogo }));
122
+ expect(withoutDocumentsLogo['platform'].logo.id).toBe(horizontalLogo.id);
123
+ expect(withoutDocumentsLogo['logo'].id).toBe(horizontalLogo.id);
124
+
125
+ const onlySquareLogo = DocumentRenderService.buildDocumentContext(document, organization, createPlatform({ squareLogo }));
126
+ expect(onlySquareLogo['platform'].logo.id).toBe(squareLogo.id);
127
+ expect(onlySquareLogo['logo'].id).toBe(squareLogo.id);
128
+ });
129
+
130
+ test('No logo is added to the context when the platform and the organization have none', () => {
131
+ const context = DocumentRenderService.buildDocumentContext(createDocument(), createOrganization(), createPlatform({}));
132
+
133
+ expect(context['platform']).toBeUndefined();
134
+ expect(context['logo']).toBeUndefined();
135
+ expect(context['organization'].logo).toBeUndefined();
136
+ });
137
+
138
+ test('The organization logo takes precedence over the platform logo', () => {
139
+ const platformLogo = createImage('logo-documents');
140
+ const organizationLogo = createImage('organization-horizontal-logo');
141
+
142
+ const organization = createOrganization({ horizontalLogo: organizationLogo });
143
+ const context = DocumentRenderService.buildDocumentContext(createDocument(), organization, createPlatform({ logoDocuments: platformLogo }));
144
+
145
+ expect(context['logo'].id).toBe(organizationLogo.id);
146
+ expect(context['organization'].logo.id).toBe(organizationLogo.id);
147
+
148
+ // The platform logo remains available separately
149
+ expect(context['platform'].logo.id).toBe(platformLogo.id);
150
+ });
151
+ });
152
+
153
+ describe('getRenderedHtml', () => {
154
+ test('It returns null and logs when building the context fails', async () => {
155
+ const template = await new DocumentTemplateFactory({ groups: [] }).create();
156
+
157
+ const document = createDocument();
158
+ document.templateId = template.id;
159
+ document.organizationId = template.organizationId;
160
+ document.data.fieldAnswers = createInvalidFieldAnswers();
161
+
162
+ const consoleError = vitest.spyOn(console, 'error').mockImplementation(() => {});
163
+
164
+ try {
165
+ await expect(DocumentRenderService.getRenderedHtml(document, createOrganization())).resolves.toBeNull();
166
+ expect(consoleError).toHaveBeenCalledWith('Failed to render document html', expect.any(Error));
167
+ } finally {
168
+ consoleError.mockRestore();
169
+ }
170
+ });
171
+
172
+ test('It returns null when the template no longer exists', async () => {
173
+ const document = createDocument();
174
+ document.templateId = '00000000-0000-4000-8000-000000000000';
175
+
176
+ await expect(DocumentRenderService.getRenderedHtml(document, createOrganization())).resolves.toBeNull();
177
+ });
178
+ });
179
+
180
+ describe('getRenderedXml', () => {
181
+ test('It renumbers the published documents to be strictly increasing and saves them', async () => {
182
+ const { template, organization, documents } = await createTemplateWithDocuments([1, 3, 7]);
183
+
184
+ const xml = await DocumentRenderService.getRenderedXml(template, organization);
185
+
186
+ expect(xml).toBe('<documents><document>1</document><document>2</document><document>3</document></documents>');
187
+
188
+ // The gaps are closed in the database too
189
+ for (const [index, document] of documents.entries()) {
190
+ const reloaded = await Document.getByID(document.id);
191
+ expect(reloaded!.number).toBe(index + 1);
192
+ }
193
+ });
194
+
195
+ test('It rethrows a SimpleError instead of swallowing it', async () => {
196
+ // A locked document cannot be renumbered: saving it throws a SimpleError
197
+ const { template, organization } = await createTemplateWithDocuments([2], { lockDocuments: true });
198
+
199
+ const error = await DocumentRenderService.getRenderedXml(template, organization).then(() => null, (e: unknown) => e);
200
+
201
+ expect(error).toBeInstanceOf(SimpleError);
202
+ expect((error as SimpleError).code).toBe('locked');
203
+ });
204
+
205
+ test('It returns null and logs for any other error', async () => {
206
+ const { template, organization } = await createTemplateWithDocuments([1]);
207
+
208
+ template.settings.fieldAnswers = createInvalidFieldAnswers();
209
+
210
+ const consoleError = vitest.spyOn(console, 'error').mockImplementation(() => {});
211
+
212
+ try {
213
+ await expect(DocumentRenderService.getRenderedXml(template, organization)).resolves.toBeNull();
214
+ expect(consoleError).toHaveBeenCalledWith('Failed to render document html', expect.any(Error));
215
+ } finally {
216
+ consoleError.mockRestore();
217
+ }
218
+ });
219
+
220
+ test('It returns null without building anything when the template has no xml export', async () => {
221
+ const { template, organization, documents } = await createTemplateWithDocuments([2], { xmlExport: null });
222
+
223
+ await expect(DocumentRenderService.getRenderedXml(template, organization)).resolves.toBeNull();
224
+
225
+ // No renumbering happened
226
+ expect((await Document.getByID(documents[0]!.id))!.number).toBe(2);
227
+ });
228
+ });
229
+ });
@@ -0,0 +1,180 @@
1
+ import { isSimpleError, isSimpleErrors } from '@simonbackx/simple-errors';
2
+ import type { Document, Organization } from '@stamhoofd/models';
3
+ import { DocumentTemplate, Platform } from '@stamhoofd/models';
4
+ import { render } from '@stamhoofd/models/helpers/Handlebars.js';
5
+ import type { Platform as PlatformStruct } from '@stamhoofd/structures';
6
+ import { DocumentStatus, Version } from '@stamhoofd/structures';
7
+ import { Sorter } from '@stamhoofd/utility';
8
+
9
+ /**
10
+ * Renders documents and document templates into HTML/XML.
11
+ */
12
+ export class DocumentRenderService {
13
+ /**
14
+ * Build the handlebars context of a single document.
15
+ */
16
+ static buildDocumentContext(document: Document, organization: Organization, platform: PlatformStruct) {
17
+ // Convert the field answers in a simplified javascript object
18
+ const data: Record<string, any> = {
19
+ id: document.id,
20
+ name: document.data.name,
21
+ number: document.number,
22
+ created_at: document.createdAt,
23
+ organization: {
24
+ name: organization.name,
25
+ companyName: organization.meta.companies[0]?.name || organization.name,
26
+ companyNumber: organization.meta.companies[0]?.companyNumber || null,
27
+ address: organization.address,
28
+ companyAddress: organization.meta.companies[0]?.address ?? organization.address,
29
+ },
30
+ };
31
+ const platformLogo = platform.config.logoDocuments ?? platform.config.horizontalLogo ?? platform.config.squareLogo;
32
+ const organizationLogo = organization.meta.horizontalLogo ?? organization.meta.squareLogo;
33
+ const logo = organizationLogo || platformLogo;
34
+
35
+ if (organizationLogo) {
36
+ data['organization'] = {
37
+ ...data['organization'],
38
+ logo: organizationLogo.encode({ version: Version }) ?? null,
39
+ };
40
+ }
41
+
42
+ if (platformLogo) {
43
+ data['platform'] = {
44
+ logo: platformLogo.encode({ version: Version }) ?? null,
45
+ };
46
+ }
47
+
48
+ if (logo) {
49
+ data['logo'] = logo.encode({ version: Version }) ?? null;
50
+ }
51
+
52
+ for (const field of document.data.fieldAnswers.values()) {
53
+ const keys = field.settings.id.split('.');
54
+ let current = data;
55
+ const lastKey = keys.pop()!;
56
+ if (!lastKey) {
57
+ throw new Error('Invalid field id');
58
+ }
59
+ for (const key of keys) {
60
+ if (!current[key]) {
61
+ current[key] = {};
62
+ }
63
+ current = current[key];
64
+
65
+ if (typeof current !== 'object') {
66
+ throw new Error('Invalid field type');
67
+ }
68
+ }
69
+ current[lastKey] = field.objectValue;
70
+ }
71
+ return data;
72
+ }
73
+
74
+ /**
75
+ * Build the handlebars context of a whole template.
76
+ *
77
+ * Note that this writes: an XML export is the moment the numbering becomes final, so any gaps
78
+ * left by deleted documents are closed and the new numbers are saved.
79
+ */
80
+ private static async buildTemplateContext(template: DocumentTemplate, organization: Organization) {
81
+ // Convert the field answers in a simplified javascript object
82
+ const documents = (await template.buildAll({ generateNumbers: true })).filter(d => d.status === DocumentStatus.Published && !!d.number).sort((a, b) => Sorter.byNumberValue(b.number ?? 0, a.number ?? 0));
83
+
84
+ // Check numbers are strictly increasing
85
+ let lastNumber = 0;
86
+ for (const document of documents) {
87
+ if (document.number !== lastNumber + 1) {
88
+ document.number = lastNumber + 1;
89
+ await document.save();
90
+ }
91
+ lastNumber = document.number;
92
+ }
93
+
94
+ const platform = await Platform.getSharedStruct();
95
+
96
+ const data: Record<string, any> = {
97
+ id: template.id,
98
+ created_at: template.createdAt,
99
+ documents: documents.map(d => this.buildDocumentContext(d, organization, platform)),
100
+ organization: {
101
+ name: organization.name,
102
+ companyName: organization.meta.companies[0]?.name || organization.name,
103
+ companyNumber: organization.meta.companies[0]?.companyNumber || null,
104
+ address: organization.address,
105
+ companyAddress: organization.meta.companies[0]?.address ?? organization.address,
106
+ },
107
+ };
108
+
109
+ for (const field of template.settings.fieldAnswers.values()) {
110
+ const keys = field.settings.id.split('.');
111
+ let current = data;
112
+ const lastKey = keys.pop()!;
113
+ if (!lastKey) {
114
+ throw new Error('Invalid field id');
115
+ }
116
+ for (const key of keys) {
117
+ if (!current[key]) {
118
+ current[key] = {};
119
+ }
120
+ current = current[key];
121
+
122
+ if (typeof current !== 'object') {
123
+ throw new Error('Invalid field type');
124
+ }
125
+ }
126
+ current[lastKey] = field.objectValue;
127
+ }
128
+
129
+ return data;
130
+ }
131
+
132
+ /**
133
+ * Render a document with the html of the template it belongs to.
134
+ */
135
+ static async getRenderedHtml(document: Document, organization: Organization): Promise<string | null> {
136
+ const template = await DocumentTemplate.getByID(document.templateId);
137
+ if (!template) {
138
+ return null;
139
+ }
140
+
141
+ return await this.getRenderedHtmlForTemplate(document, organization, template.html);
142
+ }
143
+
144
+ private static async getRenderedHtmlForTemplate(document: Document, organization: Organization, htmlTemplate: string): Promise<string | null> {
145
+ try {
146
+ const platform = await Platform.getSharedStruct();
147
+ const context = this.buildDocumentContext(document, organization, platform);
148
+ const renderedHtml = await render(htmlTemplate, context);
149
+ return renderedHtml;
150
+ } catch (e) {
151
+ console.error('Failed to render document html', e);
152
+ return null;
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Render the XML export of a template, containing all its published documents.
158
+ *
159
+ * Unlike getRenderedHtml this rethrows SimpleErrors: building the context finalises the document
160
+ * numbering, and a failure there (a locked document for example) has to reach the user instead of
161
+ * silently turning into a missing export.
162
+ */
163
+ static async getRenderedXml(template: DocumentTemplate, organization: Organization): Promise<string | null> {
164
+ if (!template.privateSettings.templateDefinition.xmlExport) {
165
+ return null;
166
+ }
167
+
168
+ try {
169
+ const context = await this.buildTemplateContext(template, organization);
170
+ const renderedHtml = await render(template.privateSettings.templateDefinition.xmlExport, context);
171
+ return renderedHtml;
172
+ } catch (e) {
173
+ if (isSimpleError(e) || isSimpleErrors(e)) {
174
+ throw e;
175
+ }
176
+ console.error('Failed to render document html', e);
177
+ return null;
178
+ }
179
+ }
180
+ }