@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.
- package/package.json +26 -17
- package/src/boot.ts +5 -0
- package/src/crons/balance-emails.ts +2 -1
- package/src/crons/delete-expired-mfa-tokens.ts +35 -0
- package/src/crons/drip-emails.ts +2 -1
- package/src/crons/index.ts +1 -0
- package/src/crons/invoices.ts +5 -3
- package/src/crons.ts +4 -2
- package/src/email-recipient-loaders/orders.ts +2 -1
- package/src/email-recipient-loaders/organizations.ts +3 -2
- package/src/email-recipient-loaders/payments.ts +3 -2
- package/src/endpoints/auth/ConfirmTOTPEndpoint.ts +84 -0
- package/src/endpoints/auth/CreateAdminEndpoint.ts +4 -2
- package/src/endpoints/auth/CreateTokenEndpoint.test.ts +61 -1
- package/src/endpoints/auth/CreateTokenEndpoint.ts +135 -7
- package/src/endpoints/auth/DeletePasskeyEndpoint.ts +61 -0
- package/src/endpoints/auth/DeleteTOTPEndpoint.ts +62 -0
- package/src/endpoints/auth/DeleteUserEndpoint.ts +1 -1
- package/src/endpoints/auth/ForgotPasswordEndpoint.ts +4 -2
- package/src/endpoints/auth/GetMFAChallengeEndpoint.ts +56 -0
- package/src/endpoints/auth/GetMFAStatusEndpoint.ts +31 -0
- package/src/endpoints/auth/GetUserEndpoint.test.ts +32 -1
- package/src/endpoints/auth/MFA.security.test.ts +688 -0
- package/src/endpoints/auth/MFA.test.ts +1398 -0
- package/src/endpoints/auth/OpenIDConnectAuthTokenEndpoint.ts +2 -2
- package/src/endpoints/auth/PatchUserEndpoint.ts +6 -3
- package/src/endpoints/auth/RegenerateRecoveryCodesEndpoint.ts +51 -0
- package/src/endpoints/auth/RegisterPasskeyEndpoint.ts +99 -0
- package/src/endpoints/auth/RegisterPasskeyOptionsEndpoint.ts +49 -0
- package/src/endpoints/auth/RetryEmailVerificationEndpoint.ts +2 -1
- package/src/endpoints/auth/SetupTOTPEndpoint.ts +54 -0
- package/src/endpoints/auth/SignupEndpoint.ts +17 -4
- package/src/endpoints/auth/VerifyEmailEndpoint.ts +16 -0
- package/src/endpoints/global/email/CreateEmailEndpoint.ts +5 -3
- package/src/endpoints/global/email/GetAdminEmailsEndpoint.test.ts +1 -1
- package/src/endpoints/global/email/GetAdminEmailsEndpoint.ts +2 -1
- package/src/endpoints/global/email/GetEmailEndpoint.ts +2 -1
- package/src/endpoints/global/email/GetUserEmailsEndpoint.test.ts +1 -1
- package/src/endpoints/global/email/GetUserEmailsEndpoint.ts +2 -1
- package/src/endpoints/global/email/PatchEmailEndpoint.ts +5 -3
- package/src/endpoints/global/email-recipients/GetEmailRecipientsEndpoint.ts +2 -1
- package/src/endpoints/global/email-recipients/RetryEmailRecipientEndpoint.ts +2 -1
- package/src/endpoints/global/files/ExportToExcelEndpoint.ts +11 -6
- package/src/endpoints/global/files/UploadFile.test.ts +206 -0
- package/src/endpoints/global/files/UploadFile.ts +31 -6
- package/src/endpoints/global/files/UploadImage.test.ts +177 -0
- package/src/endpoints/global/files/UploadImage.ts +23 -4
- package/src/endpoints/global/files/upload-security.test.ts +837 -0
- package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +1 -1
- package/src/endpoints/global/members/SendMemberSecurityCodeEndpoint.ts +2 -1
- package/src/endpoints/global/organizations/CreateOrganizationEndpoint.ts +2 -1
- package/src/endpoints/global/platform/GetPlatformAdminsEndpoint.ts +4 -1
- package/src/endpoints/global/platform/PatchPlatformEnpoint.test.ts +35 -1
- package/src/endpoints/global/platform/PatchPlatformEnpoint.ts +8 -0
- package/src/endpoints/global/platform/SignOutPlatformAdminsEndpoint.test.ts +92 -0
- package/src/endpoints/global/platform/SignOutPlatformAdminsEndpoint.ts +43 -0
- package/src/endpoints/organization/dashboard/documents/GetDocumentTemplateXML.ts +2 -1
- package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +1 -0
- package/src/endpoints/organization/dashboard/organization/SetOrganizationDomainEndpoint.ts +3 -2
- package/src/endpoints/organization/dashboard/receivable-balances/ChargeReceivableBalancesEndpoint.ts +3 -2
- package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.test.ts +32 -3
- package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.ts +4 -1
- package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.test.ts +100 -0
- package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.ts +4 -1
- package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.test.ts +5 -5
- package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.ts +5 -3
- package/src/endpoints/organization/dashboard/users/SignOutOrganizationAdminsEndpoint.test.ts +160 -0
- package/src/endpoints/organization/dashboard/users/SignOutOrganizationAdminsEndpoint.ts +44 -0
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +4 -3
- package/src/endpoints/organization/shared/GetDocumentHtml.ts +2 -1
- package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +4 -3
- package/src/excel-loaders/balance-items.ts +1 -1
- package/src/excel-loaders/event-notifications.ts +6 -7
- package/src/excel-loaders/members.test.ts +2 -2
- package/src/excel-loaders/members.ts +9 -10
- package/src/excel-loaders/organizations.ts +14 -20
- package/src/excel-loaders/payments.ts +1 -1
- package/src/excel-loaders/platform-memberships.ts +7 -7
- package/src/excel-loaders/platform-sheets.test.ts +113 -0
- package/src/excel-loaders/receivable-balances.ts +1 -1
- package/src/excel-loaders/registrations.ts +9 -9
- package/src/helpers/AdminPermissionChecker.ts +1 -1
- package/src/helpers/AuthenticatedStructures.ts +21 -10
- package/src/helpers/Context.ts +114 -4
- package/src/helpers/EmailBuilder.test.ts +287 -0
- package/src/helpers/EmailBuilder.ts +811 -0
- package/src/helpers/EmailResumer.ts +2 -1
- package/src/helpers/ForwardHandler.ts +2 -1
- package/src/helpers/MFAEncryption.ts +34 -0
- package/src/helpers/MembershipCharger.ts +3 -2
- package/src/helpers/RecoveryCodeHelper.ts +81 -0
- package/src/helpers/TOTPHelper.ts +79 -0
- package/src/helpers/TenantContext.test.ts +96 -0
- package/src/helpers/TenantContext.ts +67 -0
- package/src/helpers/TwoFactorHelper.ts +356 -0
- package/src/helpers/WebauthnHelper.ts +211 -0
- package/src/helpers/data/aaguids.json +1006 -0
- package/src/middleware/TenantScopeMiddleware.test.ts +41 -0
- package/src/middleware/TenantScopeMiddleware.ts +24 -0
- package/src/seeds/1785417302-fill-user-last-active-at.sql +6 -0
- package/src/services/AdminSessionService.ts +41 -0
- package/src/services/BalanceItemService.ts +2 -1
- package/src/services/DocumentRenderService.test.ts +229 -0
- package/src/services/DocumentRenderService.ts +180 -0
- package/src/services/EmailPreviewService.test.ts +300 -0
- package/src/services/EmailPreviewService.ts +239 -0
- package/src/services/EmailSendService.test.ts +1218 -0
- package/src/services/EmailSendService.ts +705 -0
- package/src/services/EventNotificationService.ts +2 -1
- package/src/services/FileSignService.test.ts +85 -0
- package/src/services/FileSignService.ts +23 -1
- package/src/services/InvoicePdfService.ts +3 -3
- package/src/services/InvoiceService.ts +4 -2
- package/src/services/InvoiceXMLService.ts +2 -1
- package/src/services/OrderService.test.ts +308 -0
- package/src/services/OrderService.ts +215 -0
- package/src/services/OrganizationAdminService.test.ts +47 -0
- package/src/services/OrganizationAdminService.ts +139 -0
- package/src/services/OrganizationDNSService.test.ts +177 -0
- package/src/services/OrganizationDNSService.ts +282 -0
- package/src/services/OrganizationEmailService.test.ts +57 -0
- package/src/services/OrganizationEmailService.ts +212 -0
- package/src/services/PasswordForgotService.test.ts +99 -0
- package/src/services/PasswordForgotService.ts +38 -0
- package/src/services/PaymentService.ts +5 -3
- package/src/services/PlatformMembershipService.test.ts +180 -0
- package/src/services/PlatformMembershipService.ts +281 -4
- package/src/services/ReferralService.ts +3 -2
- package/src/services/RegistrationService.ts +4 -2
- package/src/services/SSOService.ts +106 -14
- package/src/services/STPackageService.test.ts +191 -0
- package/src/services/STPackageService.ts +115 -1
- package/src/services/TwoFactorAuditLogService.ts +64 -0
- package/src/services/VerificationCodeService.test.ts +71 -0
- package/src/services/VerificationCodeService.ts +101 -0
- package/tests/e2e/api-rate-limits.test.ts +1 -1
- package/tests/e2e/documents.test.ts +2 -1
- package/tests/e2e/private-files.test.ts +77 -14
- package/tests/helpers/MFATestHelper.ts +42 -0
- package/tests/helpers/TestServer.ts +4 -0
- package/tests/helpers/index.ts +1 -0
- 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,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
|
|
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
|
+
}
|