@stamhoofd/backend 2.137.3 → 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.
Files changed (68) hide show
  1. package/package.json +23 -17
  2. package/src/crons/drip-emails.ts +2 -1
  3. package/src/crons.ts +4 -2
  4. package/src/email-recipient-loaders/payments.ts +3 -2
  5. package/src/endpoints/auth/CreateAdminEndpoint.ts +3 -2
  6. package/src/endpoints/auth/CreateTokenEndpoint.ts +2 -1
  7. package/src/endpoints/auth/ForgotPasswordEndpoint.ts +3 -2
  8. package/src/endpoints/auth/PatchUserEndpoint.ts +6 -3
  9. package/src/endpoints/auth/RetryEmailVerificationEndpoint.ts +2 -1
  10. package/src/endpoints/auth/SignupEndpoint.ts +4 -2
  11. package/src/endpoints/global/email/CreateEmailEndpoint.ts +2 -1
  12. package/src/endpoints/global/email/GetAdminEmailsEndpoint.test.ts +1 -1
  13. package/src/endpoints/global/email/GetAdminEmailsEndpoint.ts +2 -1
  14. package/src/endpoints/global/email/GetEmailEndpoint.ts +2 -1
  15. package/src/endpoints/global/email/GetUserEmailsEndpoint.test.ts +1 -1
  16. package/src/endpoints/global/email/GetUserEmailsEndpoint.ts +2 -1
  17. package/src/endpoints/global/email/PatchEmailEndpoint.ts +2 -1
  18. package/src/endpoints/global/files/ExportToExcelEndpoint.ts +9 -5
  19. package/src/endpoints/global/files/UploadFile.test.ts +206 -0
  20. package/src/endpoints/global/files/UploadFile.ts +31 -6
  21. package/src/endpoints/global/files/UploadImage.test.ts +177 -0
  22. package/src/endpoints/global/files/UploadImage.ts +23 -4
  23. package/src/endpoints/global/files/upload-security.test.ts +837 -0
  24. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +1 -1
  25. package/src/endpoints/global/organizations/CreateOrganizationEndpoint.ts +2 -1
  26. package/src/endpoints/organization/dashboard/documents/GetDocumentTemplateXML.ts +2 -1
  27. package/src/endpoints/organization/dashboard/organization/SetOrganizationDomainEndpoint.ts +3 -2
  28. package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.ts +5 -3
  29. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +4 -3
  30. package/src/endpoints/organization/shared/GetDocumentHtml.ts +2 -1
  31. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +4 -3
  32. package/src/excel-loaders/balance-items.ts +1 -1
  33. package/src/excel-loaders/event-notifications.ts +6 -7
  34. package/src/excel-loaders/members.test.ts +2 -2
  35. package/src/excel-loaders/members.ts +9 -10
  36. package/src/excel-loaders/organizations.ts +14 -20
  37. package/src/excel-loaders/payments.ts +1 -1
  38. package/src/excel-loaders/platform-memberships.ts +7 -7
  39. package/src/excel-loaders/platform-sheets.test.ts +113 -0
  40. package/src/excel-loaders/receivable-balances.ts +1 -1
  41. package/src/excel-loaders/registrations.ts +9 -9
  42. package/src/helpers/AdminPermissionChecker.ts +1 -1
  43. package/src/helpers/AuthenticatedStructures.ts +12 -9
  44. package/src/helpers/MembershipCharger.ts +3 -2
  45. package/src/services/BalanceItemService.ts +2 -1
  46. package/src/services/DocumentRenderService.test.ts +229 -0
  47. package/src/services/DocumentRenderService.ts +180 -0
  48. package/src/services/EmailPreviewService.test.ts +300 -0
  49. package/src/services/EmailPreviewService.ts +239 -0
  50. package/src/services/FileSignService.test.ts +85 -0
  51. package/src/services/FileSignService.ts +23 -1
  52. package/src/services/OrderService.test.ts +308 -0
  53. package/src/services/OrderService.ts +214 -0
  54. package/src/services/OrganizationDNSService.test.ts +177 -0
  55. package/src/services/OrganizationDNSService.ts +282 -0
  56. package/src/services/OrganizationEmailService.test.ts +57 -0
  57. package/src/services/OrganizationEmailService.ts +211 -0
  58. package/src/services/PasswordForgotService.test.ts +99 -0
  59. package/src/services/PasswordForgotService.ts +38 -0
  60. package/src/services/PlatformMembershipService.test.ts +180 -0
  61. package/src/services/PlatformMembershipService.ts +281 -4
  62. package/src/services/RegistrationService.ts +3 -2
  63. package/src/services/STPackageService.test.ts +191 -0
  64. package/src/services/STPackageService.ts +114 -2
  65. package/src/services/VerificationCodeService.test.ts +71 -0
  66. package/src/services/VerificationCodeService.ts +100 -0
  67. package/tests/e2e/documents.test.ts +2 -1
  68. 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, Platform as PlatformStruct, UnencodeablePaginatedResponse } from '@stamhoofd/structures';
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 { baseMemberColumns } from './members.js';
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 sheet: XlsxTransformerSheet<PlatformMember, PlatformRegistration> = {
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
- ...baseMemberColumns.map(column => XlsxTransformerColumnHelper.transformColumnForProperty({
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: PlatformStruct.shared.config.defaultAgeGroups.find(g => g.id === defaultAgeGroupId)?.name ?? $t(`%wJ`),
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
- sheets: [
374
- sheet,
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, Platform, PrivateOrder, PrivateWebshop, ReceivableBalanceObject, ReceivableBalanceObjectContact, ReceivableBalance as ReceivableBalanceStruct, ReceivableBalanceType, RegistrationInvitation as RegistrationInvitationStruct, RegistrationsBlob, RegistrationWithMemberBlob, TicketPrivate, UserWithMembers, WebshopPreview, Webshop as WebshopStruct } 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, 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
- Platform.shared.period.previousPeriodId,
471
- Platform.shared.period.id,
472
- Platform.shared.period.nextPeriodId,
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 = Platform.shared.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`) + ' ' + Platform.shared.config.name,
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 membership.calculatePrice(member);
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 membership.calculatePrice(member);
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 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
+ }