@stamhoofd/backend 2.137.4 → 2.137.5
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 +23 -17
- package/src/crons/drip-emails.ts +2 -1
- package/src/crons.ts +4 -2
- package/src/email-recipient-loaders/payments.ts +3 -2
- package/src/endpoints/auth/CreateAdminEndpoint.ts +3 -2
- package/src/endpoints/auth/CreateTokenEndpoint.ts +2 -1
- package/src/endpoints/auth/ForgotPasswordEndpoint.ts +3 -2
- package/src/endpoints/auth/PatchUserEndpoint.ts +6 -3
- package/src/endpoints/auth/RetryEmailVerificationEndpoint.ts +2 -1
- package/src/endpoints/auth/SignupEndpoint.ts +4 -2
- package/src/endpoints/global/email/CreateEmailEndpoint.ts +2 -1
- 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 +2 -1
- package/src/endpoints/global/files/ExportToExcelEndpoint.ts +9 -5
- 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/organizations/CreateOrganizationEndpoint.ts +2 -1
- package/src/endpoints/organization/dashboard/documents/GetDocumentTemplateXML.ts +2 -1
- package/src/endpoints/organization/dashboard/organization/SetOrganizationDomainEndpoint.ts +3 -2
- package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.ts +5 -3
- 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 +12 -9
- package/src/helpers/MembershipCharger.ts +3 -2
- 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/FileSignService.test.ts +85 -0
- package/src/services/FileSignService.ts +23 -1
- package/src/services/OrderService.test.ts +308 -0
- package/src/services/OrderService.ts +214 -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 +211 -0
- package/src/services/PasswordForgotService.test.ts +99 -0
- package/src/services/PasswordForgotService.ts +38 -0
- package/src/services/PlatformMembershipService.test.ts +180 -0
- package/src/services/PlatformMembershipService.ts +281 -4
- package/src/services/RegistrationService.ts +3 -2
- package/src/services/STPackageService.test.ts +191 -0
- package/src/services/STPackageService.ts +114 -2
- package/src/services/VerificationCodeService.test.ts +71 -0
- package/src/services/VerificationCodeService.ts +100 -0
- package/tests/e2e/documents.test.ts +2 -1
- package/tests/e2e/private-files.test.ts +77 -14
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import type { XlsxTransformerSheet } from '@stamhoofd/excel-writer';
|
|
2
2
|
import { XlsxBuiltInNumberFormat } from '@stamhoofd/excel-writer';
|
|
3
3
|
import { Platform } from '@stamhoofd/models';
|
|
4
|
-
import type { LimitedFilteredRequest, PlatformMember } from '@stamhoofd/structures';
|
|
5
|
-
import { ExcelExportType, getGroupTypeName, PlatformRegistration,
|
|
4
|
+
import type { LimitedFilteredRequest, PlatformMember, Platform as PlatformStruct } from '@stamhoofd/structures';
|
|
5
|
+
import { ExcelExportType, getGroupTypeName, PlatformRegistration, UnencodeablePaginatedResponse } from '@stamhoofd/structures';
|
|
6
6
|
import { ExportToExcelEndpoint } from '../endpoints/global/files/ExportToExcelEndpoint.js';
|
|
7
7
|
import { GetRegistrationsEndpoint } from '../endpoints/global/registration/GetRegistrationsEndpoint.js';
|
|
8
8
|
import { AuthenticatedStructures } from '../helpers/AuthenticatedStructures.js';
|
|
9
9
|
import { Context } from '../helpers/Context.js';
|
|
10
10
|
import { XlsxTransformerColumnHelper } from '../helpers/XlsxTransformerColumnHelper.js';
|
|
11
|
-
import {
|
|
11
|
+
import { getBaseMemberColumns } from './members.js';
|
|
12
12
|
|
|
13
13
|
// Assign to a typed variable to assure we have correct type checking in place
|
|
14
|
-
const
|
|
14
|
+
const getSheet = (platform: PlatformStruct): XlsxTransformerSheet<PlatformMember, PlatformRegistration> => ({
|
|
15
15
|
id: 'registrations',
|
|
16
16
|
name: $t('%1EI'),
|
|
17
17
|
columns: [
|
|
@@ -294,7 +294,7 @@ const sheet: XlsxTransformerSheet<PlatformMember, PlatformRegistration> = {
|
|
|
294
294
|
];
|
|
295
295
|
},
|
|
296
296
|
},
|
|
297
|
-
...
|
|
297
|
+
...getBaseMemberColumns(platform).map(column => XlsxTransformerColumnHelper.transformColumnForProperty({
|
|
298
298
|
column,
|
|
299
299
|
key: 'member',
|
|
300
300
|
getPropertyValue: (registration: PlatformRegistration) => registration.member,
|
|
@@ -341,7 +341,7 @@ const sheet: XlsxTransformerSheet<PlatformMember, PlatformRegistration> = {
|
|
|
341
341
|
};
|
|
342
342
|
}
|
|
343
343
|
return {
|
|
344
|
-
value:
|
|
344
|
+
value: platform.config.defaultAgeGroups.find(g => g.id === defaultAgeGroupId)?.name ?? $t(`%wJ`),
|
|
345
345
|
};
|
|
346
346
|
},
|
|
347
347
|
},
|
|
@@ -356,7 +356,7 @@ const sheet: XlsxTransformerSheet<PlatformMember, PlatformRegistration> = {
|
|
|
356
356
|
},
|
|
357
357
|
},
|
|
358
358
|
],
|
|
359
|
-
};
|
|
359
|
+
});
|
|
360
360
|
|
|
361
361
|
ExportToExcelEndpoint.loaders.set(ExcelExportType.Registrations, {
|
|
362
362
|
fetch: async (query: LimitedFilteredRequest) => {
|
|
@@ -370,7 +370,7 @@ ExportToExcelEndpoint.loaders.set(ExcelExportType.Registrations, {
|
|
|
370
370
|
next: result.next,
|
|
371
371
|
});
|
|
372
372
|
},
|
|
373
|
-
|
|
374
|
-
|
|
373
|
+
getSheets: platform => [
|
|
374
|
+
getSheet(platform),
|
|
375
375
|
],
|
|
376
376
|
});
|
|
@@ -794,7 +794,7 @@ export class AdminPermissionChecker {
|
|
|
794
794
|
|
|
795
795
|
// Check if this user has permissions for the current scoped organization
|
|
796
796
|
if (this.organization && await this.hasFullAccess(this.organization.id)) {
|
|
797
|
-
if (user.permissions?.forOrganization(this.organization)) {
|
|
797
|
+
if (user.permissions?.forOrganization(this.organization, this.platform, { inheritFromPlatform: false })) {
|
|
798
798
|
return true;
|
|
799
799
|
}
|
|
800
800
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { SimpleError } from '@simonbackx/simple-errors';
|
|
2
2
|
import type { AuditLog, Document, EventNotification, MemberWithUsersRegistrationsAndGroups, Order, Ticket } from '@stamhoofd/models';
|
|
3
|
-
import { BalanceItem, CachedBalance, Event, Group, Invoice, Member, MemberPlatformMembership, MemberResponsibilityRecord, Organization, OrganizationRegistrationPeriod, Payment, Registration, RegistrationInvitation, RegistrationPeriod, User, Webshop } from '@stamhoofd/models';
|
|
3
|
+
import { BalanceItem, CachedBalance, Event, Group, Invoice, Member, MemberPlatformMembership, MemberResponsibilityRecord, Organization, OrganizationRegistrationPeriod, Payment, Platform as PlatformModel, Registration, RegistrationInvitation, RegistrationPeriod, User, Webshop } from '@stamhoofd/models';
|
|
4
4
|
import type { PaymentGeneral } from '@stamhoofd/structures';
|
|
5
5
|
import { BaseOrganization, getAppHost, OrganizationPrivateMetaData } from '@stamhoofd/structures';
|
|
6
|
-
import { Payment as PaymentStruct, AuditLogReplacement, AuditLogReplacementType, AuditLog as AuditLogStruct, BalanceItem as BalanceItemStruct, DetailedReceivableBalance, Document as DocumentStruct, EventNotification as EventNotificationStruct, Event as EventStruct, GenericBalance, Group as GroupStruct, GroupType, InvitationGroupData, InvitationMemberData, InvoicedBalanceItem, InvoiceStruct, MemberPlatformMembership as MemberPlatformMembershipStruct, MemberRegistrationInvitation, MembersBlob, MemberWithRegistrationsBlob, NamedObject, OrganizationRegistrationPeriod as OrganizationRegistrationPeriodStruct, Organization as OrganizationStruct, PaymentCustomer, PermissionLevel,
|
|
6
|
+
import { Payment as PaymentStruct, AuditLogReplacement, AuditLogReplacementType, AuditLog as AuditLogStruct, BalanceItem as BalanceItemStruct, DetailedReceivableBalance, Document as DocumentStruct, EventNotification as EventNotificationStruct, Event as EventStruct, GenericBalance, Group as GroupStruct, GroupType, InvitationGroupData, InvitationMemberData, InvoicedBalanceItem, InvoiceStruct, MemberPlatformMembership as MemberPlatformMembershipStruct, MemberRegistrationInvitation, MembersBlob, MemberWithRegistrationsBlob, NamedObject, OrganizationRegistrationPeriod as OrganizationRegistrationPeriodStruct, Organization as OrganizationStruct, PaymentCustomer, PermissionLevel, PrivateOrder, PrivateWebshop, ReceivableBalanceObject, ReceivableBalanceObjectContact, ReceivableBalance as ReceivableBalanceStruct, ReceivableBalanceType, RegistrationInvitation as RegistrationInvitationStruct, RegistrationsBlob, RegistrationWithMemberBlob, TicketPrivate, UserWithMembers, WebshopPreview, Webshop as WebshopStruct } from '@stamhoofd/structures';
|
|
7
7
|
import { Sorter } from '@stamhoofd/utility';
|
|
8
8
|
|
|
9
9
|
import { SQL } from '@stamhoofd/sql';
|
|
@@ -465,11 +465,12 @@ export class AuthenticatedStructures {
|
|
|
465
465
|
return MembersBlob.create({ members: [], organizations: [] });
|
|
466
466
|
}
|
|
467
467
|
|
|
468
|
+
const platform = await PlatformModel.getSharedStruct();
|
|
468
469
|
const organizations = new Map<string, Organization>();
|
|
469
470
|
const relevantPeriodIds = Formatter.uniqueArray([
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
471
|
+
platform.period.previousPeriodId,
|
|
472
|
+
platform.period.id,
|
|
473
|
+
platform.period.nextPeriodId,
|
|
473
474
|
Context.organization?.periodId ?? null,
|
|
474
475
|
].filter(id => id !== null)) as string[];
|
|
475
476
|
|
|
@@ -520,7 +521,7 @@ export class AuthenticatedStructures {
|
|
|
520
521
|
organizationIds.push(STAMHOOFD.singleOrganization);
|
|
521
522
|
}
|
|
522
523
|
|
|
523
|
-
const membershipOrganizationId =
|
|
524
|
+
const membershipOrganizationId = platform.membershipOrganizationId;
|
|
524
525
|
if (membershipOrganizationId && Context.auth.hasSomePlatformAccess()) {
|
|
525
526
|
if (await Context.auth.hasSomeAccess(membershipOrganizationId)) {
|
|
526
527
|
organizationIds.push(membershipOrganizationId);
|
|
@@ -1007,6 +1008,7 @@ export class AuthenticatedStructures {
|
|
|
1007
1008
|
...balances.filter(b => b.objectType === ReceivableBalanceType.user || b.objectType === ReceivableBalanceType.userWithoutMembers).map(b => b.objectId),
|
|
1008
1009
|
]);
|
|
1009
1010
|
const users = userIds.length > 0 ? await User.getByIDs(...userIds) : [];
|
|
1011
|
+
const platform = await PlatformModel.getSharedStruct();
|
|
1010
1012
|
|
|
1011
1013
|
const result: { balance: CachedBalance; object: ReceivableBalanceObject }[] = [];
|
|
1012
1014
|
|
|
@@ -1131,7 +1133,7 @@ export class AuthenticatedStructures {
|
|
|
1131
1133
|
} else if (balance.objectType === ReceivableBalanceType.user || balance.objectType === ReceivableBalanceType.userWithoutMembers) {
|
|
1132
1134
|
const user = users.find(m => m.id === balance.objectId) ?? null;
|
|
1133
1135
|
if (user) {
|
|
1134
|
-
const url = Context.organization && Context.organization.id === balance.organizationId ? 'https://' + getAppHost('registration', Context.organization, user.permissions?.forOrganization(Context.organization)?.isEmpty === false) : '';
|
|
1136
|
+
const url = Context.organization && Context.organization.id === balance.organizationId ? 'https://' + getAppHost('registration', Context.organization, user.permissions?.forOrganization(Context.organization, platform, { inheritFromPlatform: false })?.isEmpty === false) : '';
|
|
1135
1137
|
object = ReceivableBalanceObject.create({
|
|
1136
1138
|
id: balance.objectId,
|
|
1137
1139
|
name: user.name || user.email,
|
|
@@ -1193,6 +1195,7 @@ export class AuthenticatedStructures {
|
|
|
1193
1195
|
const users = await User.getByIDs(...userIds);
|
|
1194
1196
|
const organizationsIds = Formatter.uniqueArray(logs.map(l => l.organizationId).filter(id => id !== null));
|
|
1195
1197
|
const organizations = await Organization.getByIDs(...organizationsIds);
|
|
1198
|
+
const platform = await PlatformModel.getSharedStruct();
|
|
1196
1199
|
|
|
1197
1200
|
for (const log of logs) {
|
|
1198
1201
|
const user = log.userId ? (users.find(u => u.id === log.userId) ?? null) : null;
|
|
@@ -1200,10 +1203,10 @@ export class AuthenticatedStructures {
|
|
|
1200
1203
|
|
|
1201
1204
|
if (user) {
|
|
1202
1205
|
if (!await Context.auth.canAccessUser(user)) {
|
|
1203
|
-
if (user.permissions?.platform !== null) {
|
|
1206
|
+
if (user.permissions?.forPlatform(platform) !== null) {
|
|
1204
1207
|
userStruct = NamedObject.create({
|
|
1205
1208
|
id: '',
|
|
1206
|
-
name: $t(`%wi`) + ' ' +
|
|
1209
|
+
name: $t(`%wi`) + ' ' + platform.config.name,
|
|
1207
1210
|
});
|
|
1208
1211
|
} else {
|
|
1209
1212
|
userStruct = NamedObject.create({
|
|
@@ -3,6 +3,7 @@ import { BalanceItem, Member, MemberPlatformMembership, Platform, RegistrationPe
|
|
|
3
3
|
import { SQL, SQLOrderBy, SQLWhereSign } from '@stamhoofd/sql';
|
|
4
4
|
import { BalanceItemRelation, BalanceItemRelationType, BalanceItemType, TranslatedString } from '@stamhoofd/structures';
|
|
5
5
|
import { Formatter, sleep } from '@stamhoofd/utility';
|
|
6
|
+
import { PlatformMembershipService } from '../services/PlatformMembershipService.js';
|
|
6
7
|
|
|
7
8
|
export const MembershipCharger = {
|
|
8
9
|
async charge() {
|
|
@@ -86,7 +87,7 @@ export const MembershipCharger = {
|
|
|
86
87
|
|
|
87
88
|
// Force price update (required because could have changed - especially for free memberships in combination with deletes)
|
|
88
89
|
try {
|
|
89
|
-
await
|
|
90
|
+
await PlatformMembershipService.calculatePrice(membership, member);
|
|
90
91
|
} catch (e) {
|
|
91
92
|
console.error('Failed to update price for membership. Not charged.', membership.id, e);
|
|
92
93
|
continue;
|
|
@@ -194,7 +195,7 @@ export const MembershipCharger = {
|
|
|
194
195
|
|
|
195
196
|
// Force price update (required because could have changed - especially for free memberships in combination with deletes)
|
|
196
197
|
try {
|
|
197
|
-
await
|
|
198
|
+
await PlatformMembershipService.calculatePrice(membership, member);
|
|
198
199
|
await membership.save();
|
|
199
200
|
} catch (e) {
|
|
200
201
|
console.error('Failed to update price for membership', membership.id, e);
|
|
@@ -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
|
+
}
|