@stamhoofd/backend 2.137.4 → 2.138.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/package.json +26 -17
  2. package/src/boot.ts +5 -0
  3. package/src/crons/balance-emails.ts +2 -1
  4. package/src/crons/delete-expired-mfa-tokens.ts +35 -0
  5. package/src/crons/drip-emails.ts +2 -1
  6. package/src/crons/index.ts +1 -0
  7. package/src/crons/invoices.ts +5 -3
  8. package/src/crons.ts +4 -2
  9. package/src/email-recipient-loaders/orders.ts +2 -1
  10. package/src/email-recipient-loaders/organizations.ts +3 -2
  11. package/src/email-recipient-loaders/payments.ts +3 -2
  12. package/src/endpoints/auth/ConfirmTOTPEndpoint.ts +84 -0
  13. package/src/endpoints/auth/CreateAdminEndpoint.ts +4 -2
  14. package/src/endpoints/auth/CreateTokenEndpoint.test.ts +61 -1
  15. package/src/endpoints/auth/CreateTokenEndpoint.ts +135 -7
  16. package/src/endpoints/auth/DeletePasskeyEndpoint.ts +61 -0
  17. package/src/endpoints/auth/DeleteTOTPEndpoint.ts +62 -0
  18. package/src/endpoints/auth/DeleteUserEndpoint.ts +1 -1
  19. package/src/endpoints/auth/ForgotPasswordEndpoint.ts +4 -2
  20. package/src/endpoints/auth/GetMFAChallengeEndpoint.ts +56 -0
  21. package/src/endpoints/auth/GetMFAStatusEndpoint.ts +31 -0
  22. package/src/endpoints/auth/GetUserEndpoint.test.ts +32 -1
  23. package/src/endpoints/auth/MFA.security.test.ts +688 -0
  24. package/src/endpoints/auth/MFA.test.ts +1398 -0
  25. package/src/endpoints/auth/OpenIDConnectAuthTokenEndpoint.ts +2 -2
  26. package/src/endpoints/auth/PatchUserEndpoint.ts +6 -3
  27. package/src/endpoints/auth/RegenerateRecoveryCodesEndpoint.ts +51 -0
  28. package/src/endpoints/auth/RegisterPasskeyEndpoint.ts +99 -0
  29. package/src/endpoints/auth/RegisterPasskeyOptionsEndpoint.ts +49 -0
  30. package/src/endpoints/auth/RetryEmailVerificationEndpoint.ts +2 -1
  31. package/src/endpoints/auth/SetupTOTPEndpoint.ts +54 -0
  32. package/src/endpoints/auth/SignupEndpoint.ts +17 -4
  33. package/src/endpoints/auth/VerifyEmailEndpoint.ts +16 -0
  34. package/src/endpoints/global/email/CreateEmailEndpoint.ts +5 -3
  35. package/src/endpoints/global/email/GetAdminEmailsEndpoint.test.ts +1 -1
  36. package/src/endpoints/global/email/GetAdminEmailsEndpoint.ts +2 -1
  37. package/src/endpoints/global/email/GetEmailEndpoint.ts +2 -1
  38. package/src/endpoints/global/email/GetUserEmailsEndpoint.test.ts +1 -1
  39. package/src/endpoints/global/email/GetUserEmailsEndpoint.ts +2 -1
  40. package/src/endpoints/global/email/PatchEmailEndpoint.ts +5 -3
  41. package/src/endpoints/global/email-recipients/GetEmailRecipientsEndpoint.ts +2 -1
  42. package/src/endpoints/global/email-recipients/RetryEmailRecipientEndpoint.ts +2 -1
  43. package/src/endpoints/global/files/ExportToExcelEndpoint.ts +11 -6
  44. package/src/endpoints/global/files/UploadFile.test.ts +206 -0
  45. package/src/endpoints/global/files/UploadFile.ts +31 -6
  46. package/src/endpoints/global/files/UploadImage.test.ts +177 -0
  47. package/src/endpoints/global/files/UploadImage.ts +23 -4
  48. package/src/endpoints/global/files/upload-security.test.ts +837 -0
  49. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +1 -1
  50. package/src/endpoints/global/members/SendMemberSecurityCodeEndpoint.ts +2 -1
  51. package/src/endpoints/global/organizations/CreateOrganizationEndpoint.ts +2 -1
  52. package/src/endpoints/global/platform/GetPlatformAdminsEndpoint.ts +4 -1
  53. package/src/endpoints/global/platform/PatchPlatformEnpoint.test.ts +35 -1
  54. package/src/endpoints/global/platform/PatchPlatformEnpoint.ts +8 -0
  55. package/src/endpoints/global/platform/SignOutPlatformAdminsEndpoint.test.ts +92 -0
  56. package/src/endpoints/global/platform/SignOutPlatformAdminsEndpoint.ts +43 -0
  57. package/src/endpoints/organization/dashboard/documents/GetDocumentTemplateXML.ts +2 -1
  58. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +1 -0
  59. package/src/endpoints/organization/dashboard/organization/SetOrganizationDomainEndpoint.ts +3 -2
  60. package/src/endpoints/organization/dashboard/receivable-balances/ChargeReceivableBalancesEndpoint.ts +3 -2
  61. package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.test.ts +32 -3
  62. package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.ts +4 -1
  63. package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.test.ts +100 -0
  64. package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.ts +4 -1
  65. package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.test.ts +5 -5
  66. package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.ts +5 -3
  67. package/src/endpoints/organization/dashboard/users/SignOutOrganizationAdminsEndpoint.test.ts +160 -0
  68. package/src/endpoints/organization/dashboard/users/SignOutOrganizationAdminsEndpoint.ts +44 -0
  69. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +4 -3
  70. package/src/endpoints/organization/shared/GetDocumentHtml.ts +2 -1
  71. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +4 -3
  72. package/src/excel-loaders/balance-items.ts +1 -1
  73. package/src/excel-loaders/event-notifications.ts +6 -7
  74. package/src/excel-loaders/members.test.ts +2 -2
  75. package/src/excel-loaders/members.ts +9 -10
  76. package/src/excel-loaders/organizations.ts +14 -20
  77. package/src/excel-loaders/payments.ts +1 -1
  78. package/src/excel-loaders/platform-memberships.ts +7 -7
  79. package/src/excel-loaders/platform-sheets.test.ts +113 -0
  80. package/src/excel-loaders/receivable-balances.ts +1 -1
  81. package/src/excel-loaders/registrations.ts +9 -9
  82. package/src/helpers/AdminPermissionChecker.ts +1 -1
  83. package/src/helpers/AuthenticatedStructures.ts +21 -10
  84. package/src/helpers/Context.ts +114 -4
  85. package/src/helpers/EmailBuilder.test.ts +287 -0
  86. package/src/helpers/EmailBuilder.ts +811 -0
  87. package/src/helpers/EmailResumer.ts +2 -1
  88. package/src/helpers/ForwardHandler.ts +2 -1
  89. package/src/helpers/MFAEncryption.ts +34 -0
  90. package/src/helpers/MembershipCharger.ts +3 -2
  91. package/src/helpers/RecoveryCodeHelper.ts +81 -0
  92. package/src/helpers/TOTPHelper.ts +79 -0
  93. package/src/helpers/TenantContext.test.ts +96 -0
  94. package/src/helpers/TenantContext.ts +67 -0
  95. package/src/helpers/TwoFactorHelper.ts +356 -0
  96. package/src/helpers/WebauthnHelper.ts +211 -0
  97. package/src/helpers/data/aaguids.json +1006 -0
  98. package/src/middleware/TenantScopeMiddleware.test.ts +41 -0
  99. package/src/middleware/TenantScopeMiddleware.ts +24 -0
  100. package/src/seeds/1785417302-fill-user-last-active-at.sql +6 -0
  101. package/src/services/AdminSessionService.ts +41 -0
  102. package/src/services/BalanceItemService.ts +2 -1
  103. package/src/services/DocumentRenderService.test.ts +229 -0
  104. package/src/services/DocumentRenderService.ts +180 -0
  105. package/src/services/EmailPreviewService.test.ts +300 -0
  106. package/src/services/EmailPreviewService.ts +239 -0
  107. package/src/services/EmailSendService.test.ts +1218 -0
  108. package/src/services/EmailSendService.ts +705 -0
  109. package/src/services/EventNotificationService.ts +2 -1
  110. package/src/services/FileSignService.test.ts +85 -0
  111. package/src/services/FileSignService.ts +23 -1
  112. package/src/services/InvoicePdfService.ts +3 -3
  113. package/src/services/InvoiceService.ts +4 -2
  114. package/src/services/InvoiceXMLService.ts +2 -1
  115. package/src/services/OrderService.test.ts +308 -0
  116. package/src/services/OrderService.ts +215 -0
  117. package/src/services/OrganizationAdminService.test.ts +47 -0
  118. package/src/services/OrganizationAdminService.ts +139 -0
  119. package/src/services/OrganizationDNSService.test.ts +177 -0
  120. package/src/services/OrganizationDNSService.ts +282 -0
  121. package/src/services/OrganizationEmailService.test.ts +57 -0
  122. package/src/services/OrganizationEmailService.ts +212 -0
  123. package/src/services/PasswordForgotService.test.ts +99 -0
  124. package/src/services/PasswordForgotService.ts +38 -0
  125. package/src/services/PaymentService.ts +5 -3
  126. package/src/services/PlatformMembershipService.test.ts +180 -0
  127. package/src/services/PlatformMembershipService.ts +281 -4
  128. package/src/services/ReferralService.ts +3 -2
  129. package/src/services/RegistrationService.ts +4 -2
  130. package/src/services/SSOService.ts +106 -14
  131. package/src/services/STPackageService.test.ts +191 -0
  132. package/src/services/STPackageService.ts +115 -1
  133. package/src/services/TwoFactorAuditLogService.ts +64 -0
  134. package/src/services/VerificationCodeService.test.ts +71 -0
  135. package/src/services/VerificationCodeService.ts +101 -0
  136. package/tests/e2e/api-rate-limits.test.ts +1 -1
  137. package/tests/e2e/documents.test.ts +2 -1
  138. package/tests/e2e/private-files.test.ts +77 -14
  139. package/tests/helpers/MFATestHelper.ts +42 -0
  140. package/tests/helpers/TestServer.ts +4 -0
  141. package/tests/helpers/index.ts +1 -0
  142. package/tsconfig.build.json +2 -1
@@ -0,0 +1,215 @@
1
+ import { I18n } from '@stamhoofd/backend-i18n';
2
+ import type { Organization, Payment, Ticket } from '@stamhoofd/models';
3
+ import { Order, Webshop, WebshopCounter } from '@stamhoofd/models';
4
+ import { sendEmailTemplate } from '../helpers/EmailBuilder.js';
5
+ import { EmailTemplateType, OrderStatus, PaymentMethod, Recipient, Replacement, WebshopPreview, WebshopStatus, WebshopTicketType } from '@stamhoofd/structures';
6
+ import { Formatter } from '@stamhoofd/utility';
7
+
8
+ /**
9
+ * An order with its webshop, and the organization of that webshop, loaded.
10
+ */
11
+ export type OrderWithWebshop = Order & { webshop: Webshop & { organization: Organization } };
12
+
13
+ /**
14
+ * The lifecycle transitions of an order (valid / paid) and the customer emails they send.
15
+ */
16
+ export class OrderService {
17
+ /**
18
+ * Only call this once! Make sure you use the queues correctly
19
+ */
20
+ static async markPaid(order: Order, payment: Payment | null, organization: Organization, knownWebshop?: Webshop) {
21
+ if (!order.id) {
22
+ await order.save();
23
+ }
24
+ console.log('Marking order ' + order.id + ' as paid');
25
+
26
+ const webshop = (knownWebshop ?? (await Webshop.getByID(order.webshopId)))?.setRelation(Webshop.organization, organization);
27
+ if (!webshop) {
28
+ console.error('Missing webshop for order ' + order.id);
29
+ return;
30
+ }
31
+
32
+ if (order.status === OrderStatus.Deleted) {
33
+ await order.undoPaymentFailed(payment, organization);
34
+ }
35
+
36
+ const loadedOrder = order.setRelation(Order.webshop, webshop);
37
+ const { tickets, didCreateTickets } = await loadedOrder.updateTickets({ hasPaidPayment: true });
38
+
39
+ // Needs to happen before validation, because we can include the tickets in the validation that way
40
+ if (order.validAt === null) {
41
+ await this.markValid(loadedOrder, payment, tickets);
42
+ } else {
43
+ order.markUpdated();
44
+ await order.save();
45
+
46
+ if (!order.data.shouldSendPaymentUpdates) {
47
+ console.log('Skip sending paid email for order ' + order.id);
48
+ return;
49
+ }
50
+ if (order.data.customer.email.length > 0) {
51
+ if (didCreateTickets) {
52
+ await this.sendTickets(loadedOrder);
53
+ } else {
54
+ if (payment && payment.method === PaymentMethod.Transfer) {
55
+ await this.sendPaidMail(loadedOrder);
56
+ }
57
+ }
58
+ }
59
+ }
60
+ }
61
+
62
+ /**
63
+ * WARNING: this should always run inside a queue so it only runs once for the same order
64
+ * Include any tickets that are generated and should be included in the e-mail
65
+ */
66
+ static async markValid(order: OrderWithWebshop, payment: Payment | null, tickets: Ticket[]) {
67
+ const webshop = order.webshop;
68
+ const organization = webshop.organization;
69
+
70
+ console.log('Marking as valid: order ' + order.id);
71
+ const wasValid = order.validAt !== null;
72
+
73
+ if (wasValid) {
74
+ console.warn('Warning: already validated an order');
75
+ return;
76
+ }
77
+ order.validAt = new Date(); // will get flattened AFTER calculations
78
+ order.validAt.setMilliseconds(0);
79
+ order.number = await WebshopCounter.getNextNumber(webshop);
80
+
81
+ if (payment && !Order.payment.isLoaded(order)) {
82
+ order.setRelation(Order.payment, payment);
83
+ }
84
+
85
+ // Now we have a number, update the payment
86
+ if (payment && payment.method === PaymentMethod.Transfer) {
87
+ // Only now we can update the transfer description, since we need the order number as a reference
88
+ payment.transferSettings = order.getTransferSettings({ shouldThrowIfNoIban: true });
89
+ payment.generateDescription(organization, order.number.toString(), order.getTransferReplacements());
90
+ await payment.save();
91
+ }
92
+
93
+ await order.save();
94
+
95
+ if (order.data.customer.email.length > 0) {
96
+ if (tickets.length > 0) {
97
+ // Also send a copy
98
+ if (payment && payment.method === PaymentMethod.PointOfSale) {
99
+ await this.sendEmailTemplate(order, {
100
+ type: EmailTemplateType.TicketsConfirmationPOS,
101
+ });
102
+ } else {
103
+ await this.sendEmailTemplate(order, {
104
+ type: EmailTemplateType.TicketsConfirmation,
105
+ });
106
+ }
107
+ } else {
108
+ if (webshop.meta.ticketType === WebshopTicketType.None) {
109
+ if (payment && payment.method === PaymentMethod.Transfer) {
110
+ // Also send a copy
111
+ await this.sendEmailTemplate(order, {
112
+ type: EmailTemplateType.OrderConfirmationTransfer,
113
+ });
114
+ } else if (payment && payment.method === PaymentMethod.PointOfSale) {
115
+ await this.sendEmailTemplate(order, {
116
+ type: EmailTemplateType.OrderConfirmationPOS,
117
+ });
118
+ } else {
119
+ // Also send a copy
120
+ await this.sendEmailTemplate(order, {
121
+ type: EmailTemplateType.OrderConfirmationOnline,
122
+ });
123
+ }
124
+ } else {
125
+ if (payment && payment.method === PaymentMethod.Transfer) {
126
+ await this.sendEmailTemplate(order, {
127
+ type: EmailTemplateType.TicketsConfirmationTransfer,
128
+ });
129
+ } else {
130
+ console.error('Unexpected missing tickets for order where tickets are expected');
131
+ }
132
+ }
133
+ }
134
+ }
135
+
136
+ if (webshop.privateMeta.notificationEmails) {
137
+ const i18n = organization.i18n;
138
+
139
+ const webshopDashboardUrl = 'https://' + (STAMHOOFD.domains.dashboard ?? 'stamhoofd.app') + '/' + i18n.locale + '/webshops/' + Formatter.slug(webshop.meta.name) + '/orders';
140
+
141
+ // Send an email to all these notification emails
142
+ for (const email of webshop.privateMeta.notificationEmails) {
143
+ await this.sendEmailTemplate(order, {
144
+ type: EmailTemplateType.OrderNotification,
145
+ to: Recipient.create({
146
+ email,
147
+ replacements: [
148
+ Replacement.create({
149
+ token: 'orderUrl',
150
+ value: webshopDashboardUrl,
151
+ }),
152
+ ],
153
+ }),
154
+ });
155
+ }
156
+ }
157
+ }
158
+
159
+ static async sendPaidMail(order: OrderWithWebshop) {
160
+ // For a tickets webshop, where the order was marked as paid / non-paid, we should still send the tickets email
161
+ // - because the normal email is not editable
162
+ const hasTickets = order.webshop.meta.hasTickets;
163
+
164
+ await this.sendEmailTemplate(order, {
165
+ type: hasTickets ? EmailTemplateType.TicketsReceivedTransfer : EmailTemplateType.OrderReceivedTransfer,
166
+ });
167
+ }
168
+
169
+ static async sendTickets(order: OrderWithWebshop) {
170
+ await this.sendEmailTemplate(order, {
171
+ type: EmailTemplateType.TicketsReceivedTransfer,
172
+ });
173
+ }
174
+
175
+ static async sendEmailTemplate(order: OrderWithWebshop, data: {
176
+ type: EmailTemplateType;
177
+ to?: Recipient;
178
+ }) {
179
+ // Never send an email for archived webshops
180
+ if (order.webshop.meta.status === WebshopStatus.Archived) {
181
+ return;
182
+ }
183
+
184
+ // Render all $t's (recipient replacements, order tables, template...) in the language
185
+ // the customer used while placing the order, instead of the current request language.
186
+ const i18n = new I18n(order.consumerLanguage, order.webshop.organization.address.country);
187
+
188
+ await I18n.runWithLocale(i18n, async () => {
189
+ let recipient = (await order.getStructure()).getRecipient(
190
+ order.webshop.organization.getBaseStructure(),
191
+ WebshopPreview.create(order.webshop),
192
+ );
193
+
194
+ if (data.to) {
195
+ // Clear first and last name
196
+ recipient.firstName = null;
197
+ recipient.lastName = null;
198
+ recipient.language = order.webshop.meta.defaultLanguage;
199
+ recipient.replacements = recipient.replacements.filter(r => !['firstName', 'lastName'].includes(r.token));
200
+ data.to.merge(recipient);
201
+ recipient = data.to;
202
+ }
203
+
204
+ // Create e-mail builder
205
+ await sendEmailTemplate(order.webshop.organization, {
206
+ recipients: [recipient],
207
+ template: {
208
+ type: data.type,
209
+ webshop: order.webshop,
210
+ },
211
+ type: 'transactional',
212
+ });
213
+ });
214
+ }
215
+ }
@@ -0,0 +1,47 @@
1
+ import { AccessRight, PermissionLevel, PermissionRoleDetailed, Permissions } from '@stamhoofd/structures';
2
+ import { OrganizationFactory, UserFactory } from '@stamhoofd/models';
3
+ import { OrganizationAdminService } from './OrganizationAdminService.js';
4
+
5
+ describe('Organization admin recipients', () => {
6
+ async function createOrganizationWithAdmins() {
7
+ const financeRole = PermissionRoleDetailed.create({
8
+ name: 'financial director',
9
+ accessRights: [AccessRight.OrganizationFinanceDirector],
10
+ });
11
+
12
+ const organization = await new OrganizationFactory({ roles: [financeRole] }).create();
13
+
14
+ const fullAdmin = await new UserFactory({
15
+ organization,
16
+ email: 'full-admin@example.com',
17
+ permissions: Permissions.create({ level: PermissionLevel.Full }),
18
+ }).create();
19
+
20
+ const financeAdmin = await new UserFactory({
21
+ organization,
22
+ email: 'finance-admin@example.com',
23
+ permissions: Permissions.create({
24
+ level: PermissionLevel.None,
25
+ roles: [financeRole],
26
+ }),
27
+ }).create();
28
+
29
+ return { organization, fullAdmin, financeAdmin };
30
+ }
31
+
32
+ test('The finance admin recipients include a financial director without full access', async () => {
33
+ const { organization, fullAdmin, financeAdmin } = await createOrganizationWithAdmins();
34
+
35
+ const emails = (await OrganizationAdminService.getFinanceAdminRecipients(organization)).map(r => r.email);
36
+
37
+ expect(emails).toIncludeSameMembers([fullAdmin.email, financeAdmin.email]);
38
+ });
39
+
40
+ test('The regular admin recipients only include full admins', async () => {
41
+ const { organization, fullAdmin } = await createOrganizationWithAdmins();
42
+
43
+ const emails = (await OrganizationAdminService.getAdminRecipients(organization)).map(r => r.email);
44
+
45
+ expect(emails).toIncludeSameMembers([fullAdmin.email]);
46
+ });
47
+ });
@@ -0,0 +1,139 @@
1
+ import type { Organization } from '@stamhoofd/models';
2
+ import { Platform, Token, User } from '@stamhoofd/models';
3
+ import type { EmailInterfaceRecipient } from '@stamhoofd/email';
4
+ import type { OrganizationEmail } from '@stamhoofd/structures';
5
+ import { AccessRight, Recipient } from '@stamhoofd/structures';
6
+ import { Sorter } from '@stamhoofd/utility';
7
+
8
+ /**
9
+ * Resolves which administrators of an organization should receive a given kind of email.
10
+ */
11
+ export class OrganizationAdminService {
12
+ /**
13
+ * E-mail address when we receive replies for organization@stamhoofd.email.
14
+ * Note that this sould be private because it can contain personal e-mail addresses if the organization is not configured correctly
15
+ */
16
+ static async getReplyEmails(organization: Organization): Promise<EmailInterfaceRecipient[]> {
17
+ const sender: OrganizationEmail | undefined = organization.privateMeta.emails.find(e => e.default) ?? organization.privateMeta.emails[0];
18
+
19
+ if (sender) {
20
+ return [
21
+ {
22
+ name: sender.name,
23
+ email: sender.email,
24
+ },
25
+ ];
26
+ }
27
+
28
+ return await this.getAdminToEmails(organization);
29
+ }
30
+
31
+ static async getAdmins(organization: Organization) {
32
+ return await User.getAdmins(organization.id, { verified: true });
33
+ }
34
+
35
+ /**
36
+ * These email addresess are private
37
+ */
38
+ static async getFullAdmins(organization: Organization) {
39
+ const admins = await this.getAdmins(organization);
40
+ const platform = await Platform.getSharedStruct();
41
+
42
+ // Only full access
43
+ return admins.filter(a => a.permissions && a.permissions.forOrganization(organization, platform, { inheritFromPlatform: false })?.hasFullAccess());
44
+ }
45
+
46
+ /**
47
+ * These email addresess are private
48
+ */
49
+ static async getFinanceAdmins(organization: Organization) {
50
+ const admins = await this.getAdmins(organization);
51
+ const platform = await Platform.getSharedStruct();
52
+ const filtered = admins.filter(a => a.permissions && (a.permissions.forOrganization(organization, platform, { inheritFromPlatform: false })?.hasFullAccess() || a.permissions.forOrganization(organization, platform, { inheritFromPlatform: false })?.hasAccessRight(AccessRight.OrganizationFinanceDirector)));
53
+
54
+ // Only full access
55
+ return filtered;
56
+ }
57
+
58
+ /**
59
+ * These email addresess are private
60
+ */
61
+ static async getAdminToEmails(organization: Organization): Promise<EmailInterfaceRecipient[]> {
62
+ const filtered = await this.getFullAdmins(organization);
63
+
64
+ if (STAMHOOFD.environment === 'production') {
65
+ if (filtered.length > 1) {
66
+ // remove stamhoofd email addresses
67
+ const f = filtered.flatMap(f => f.getEmailTo()).filter(e => !e.email.endsWith('@stamhoofd.be') && !e.email.endsWith('@stamhoofd.nl'));
68
+ if (f.length > 0) {
69
+ return f;
70
+ }
71
+ }
72
+ }
73
+
74
+ return filtered.flatMap(f => f.getEmailTo());
75
+ }
76
+
77
+ static adminsToRecipients(admins: User[]) {
78
+ return admins.flatMap((f) => {
79
+ return Recipient.create({
80
+ firstName: f.firstName,
81
+ lastName: f.lastName,
82
+ email: f.email,
83
+ replacements: [],
84
+ });
85
+ });
86
+ }
87
+
88
+ /**
89
+ * These email addresess are private
90
+ */
91
+ static async getAdminRecipients(organization: Organization): Promise<Recipient[]> {
92
+ const filtered = await this.getFullAdmins(organization);
93
+ return this.adminsToRecipients(filtered);
94
+ }
95
+
96
+ /**
97
+ * These email addresess are private
98
+ */
99
+ static async getFinanceAdminRecipients(organization: Organization): Promise<Recipient[]> {
100
+ const filtered = await this.getFinanceAdmins(organization);
101
+ return this.adminsToRecipients(filtered);
102
+ }
103
+
104
+ /**
105
+ * Returns one email for invoices. since in ubl we can only add one address.
106
+ * We choose the oldest user that was active in the last 3 months (otherwise the oldest user if noone was active)
107
+ */
108
+ static async getInvoicingToEmail(organization: Organization): Promise<string | undefined> {
109
+ const admins = await this.getAdmins(organization);
110
+
111
+ const tokens = await Token.select().where('userId', admins.map(a => a.id)).fetch();
112
+
113
+ // Sort by admins that were active in the last 3 months, then creation date
114
+ const cutoffDate = new Date(Date.now() - 1000 * 60 * 60 * 24 * 31 * 3);
115
+ admins.sort((a, b) => {
116
+ const aTokens = tokens.filter(t => t.userId === a.id);
117
+ const bTokens = tokens.filter(t => t.userId === b.id);
118
+ const aActive = !!aTokens.find(t => t.updatedAt > cutoffDate);
119
+ const bActive = !!bTokens.find(t => t.updatedAt > cutoffDate);
120
+ return Sorter.stack(
121
+ Sorter.byBooleanValue(aActive, bActive),
122
+ Sorter.byDateValue(b.createdAt, a.createdAt),
123
+ );
124
+ });
125
+
126
+ const platform = await Platform.getSharedStruct();
127
+ const filtered = admins.filter(a => a.verified && a.permissions && !a.email.endsWith('@stamhoofd.be') && (a.permissions.forOrganization(organization, platform, { inheritFromPlatform: false })?.hasFullAccess() || a.permissions.forOrganization(organization, platform, { inheritFromPlatform: false })?.hasAccessRight(AccessRight.OrganizationFinanceDirector)));
128
+
129
+ if (filtered.length > 0) {
130
+ return filtered.map(f => f.email)[0];
131
+ }
132
+ const filtered2 = admins.filter(a => a.verified && a.permissions && (a.permissions.forOrganization(organization, platform, { inheritFromPlatform: false })?.hasFullAccess() || a.permissions.forOrganization(organization, platform, { inheritFromPlatform: false })?.hasAccessRight(AccessRight.OrganizationFinanceDirector)));
133
+
134
+ if (filtered2.length > 0) {
135
+ return filtered2.map(f => f.email)[0];
136
+ }
137
+ return undefined;
138
+ }
139
+ }
@@ -0,0 +1,177 @@
1
+ import { EmailMocker } from '@stamhoofd/email';
2
+ import type { Organization } from '@stamhoofd/models';
3
+ import { EmailTemplateFactory, OrganizationFactory, UserFactory } from '@stamhoofd/models';
4
+ import { EmailTemplateType, PermissionLevel, Permissions } from '@stamhoofd/structures';
5
+ import { TestUtils } from '@stamhoofd/test-utils';
6
+ import { OrganizationDNSService } from './OrganizationDNSService.js';
7
+
8
+ const validateDNSRecordsMock = vi.hoisted(() => vi.fn());
9
+
10
+ vi.mock('@stamhoofd/models/helpers/DNSValidator.js', () => ({
11
+ validateDNSRecords: validateDNSRecordsMock,
12
+ }));
13
+
14
+ const templateTypes = [
15
+ EmailTemplateType.OrganizationStableDNS,
16
+ EmailTemplateType.OrganizationUnstableDNS,
17
+ EmailTemplateType.OrganizationValidDNS,
18
+ EmailTemplateType.OrganizationInvalidDNS,
19
+ EmailTemplateType.OrganizationDNSSetupComplete,
20
+ ];
21
+
22
+ async function createTemplates() {
23
+ for (const type of templateTypes) {
24
+ await new EmailTemplateFactory({
25
+ type,
26
+ subject: type,
27
+ html: '<p>{{organizationName}} - {{mailDomain}}</p>',
28
+ text: '{{organizationName}} - {{mailDomain}}',
29
+ }).create();
30
+ }
31
+ }
32
+
33
+ async function createOrganizationWithAdmin(): Promise<Organization> {
34
+ const organization = await new OrganizationFactory({}).create();
35
+ await new UserFactory({
36
+ organization,
37
+ email: 'admin@example.com',
38
+ firstName: 'Ad',
39
+ lastName: 'Min',
40
+ permissions: Permissions.create({ level: PermissionLevel.Full }),
41
+ }).create();
42
+ return organization;
43
+ }
44
+
45
+ function daysAgo(days: number) {
46
+ return new Date(Date.now() - days * 24 * 60 * 60 * 1000);
47
+ }
48
+
49
+ function setupEnvironment() {
50
+ TestUtils.setEnvironment('userMode', 'organization');
51
+
52
+ // The notification emails localize the marketing domain, which the shared test environment does not set
53
+ TestUtils.setEnvironment('domains', {
54
+ ...STAMHOOFD.domains,
55
+ marketing: { '': 'stamhoofd.dev', BE: 'be.stamhoofd.dev', NL: 'nl.stamhoofd.dev' },
56
+ });
57
+ }
58
+
59
+ describe('OrganizationDNSService.updateDNSRecords notifications', () => {
60
+ beforeAll(async () => {
61
+ await createTemplates();
62
+ });
63
+
64
+ beforeEach(() => {
65
+ setupEnvironment();
66
+ validateDNSRecordsMock.mockReset();
67
+ });
68
+
69
+ test('Sends the setup complete email the first time the mail domain becomes valid', async () => {
70
+ validateDNSRecordsMock.mockResolvedValue({ allValid: true, hasAllNonTXT: true });
71
+
72
+ const organization = await createOrganizationWithAdmin();
73
+ organization.privateMeta.pendingMailDomain = 'mail.example.com';
74
+ organization.privateMeta.mailDomainActive = false;
75
+ organization.serverMeta.didSendDomainSetupMail = false;
76
+ await organization.save();
77
+
78
+ await OrganizationDNSService.updateDNSRecords(organization);
79
+
80
+ const emails = await EmailMocker.transactional.getSucceededEmails();
81
+ expect(emails).toHaveLength(1);
82
+ expect(emails[0].subject).toBe(EmailTemplateType.OrganizationDNSSetupComplete);
83
+ expect(emails[0].to).toContain('admin@example.com');
84
+ expect(emails[0].html).toContain('mail.example.com');
85
+ expect(emails[0].html).toContain(organization.name);
86
+ expect(emails[0].bcc).toBeUndefined();
87
+
88
+ expect(organization.privateMeta.mailDomain).toBe('mail.example.com');
89
+ expect(organization.privateMeta.pendingMailDomain).toBe(null);
90
+ expect(organization.privateMeta.mailDomainActive).toBe(true);
91
+ expect(organization.serverMeta.didSendDomainSetupMail).toBe(true);
92
+ });
93
+
94
+ test('Sends the valid DNS email instead of the setup complete email once the setup email was sent before', async () => {
95
+ validateDNSRecordsMock.mockResolvedValue({ allValid: true, hasAllNonTXT: true });
96
+
97
+ const organization = await createOrganizationWithAdmin();
98
+ organization.privateMeta.pendingMailDomain = 'mail.example.com';
99
+ organization.privateMeta.mailDomainActive = false;
100
+ organization.serverMeta.didSendDomainSetupMail = true;
101
+ organization.serverMeta.DNSRecordWarningCount = 1;
102
+ await organization.save();
103
+
104
+ await OrganizationDNSService.updateDNSRecords(organization);
105
+
106
+ const emails = await EmailMocker.transactional.getSucceededEmails();
107
+ expect(emails).toHaveLength(1);
108
+ expect(emails[0].subject).toBe(EmailTemplateType.OrganizationValidDNS);
109
+ expect(emails[0].bcc).toBeUndefined();
110
+ expect(organization.serverMeta.DNSRecordWarningCount).toBe(0);
111
+ });
112
+
113
+ test('Sends the stable DNS email with a bcc when the DNS settings become stable again', async () => {
114
+ validateDNSRecordsMock.mockResolvedValue({ allValid: true, hasAllNonTXT: true });
115
+
116
+ const organization = await createOrganizationWithAdmin();
117
+ organization.serverMeta.isDNSUnstable = true;
118
+ organization.serverMeta.lastInvalidDNSDates = [];
119
+ await organization.save();
120
+
121
+ await OrganizationDNSService.updateDNSRecords(organization);
122
+
123
+ const emails = await EmailMocker.transactional.getSucceededEmails();
124
+ expect(emails).toHaveLength(1);
125
+ expect(emails[0].subject).toBe(EmailTemplateType.OrganizationStableDNS);
126
+ expect(emails[0].bcc).toContain('simon@stamhoofd.be');
127
+ expect(organization.serverMeta.isDNSUnstable).toBe(false);
128
+ });
129
+
130
+ test('Sends the unstable DNS email with a bcc when the DNS settings become unstable', async () => {
131
+ validateDNSRecordsMock.mockResolvedValue({ allValid: false, hasAllNonTXT: false });
132
+
133
+ const organization = await createOrganizationWithAdmin();
134
+ organization.privateMeta.mailDomain = 'mail.example.com';
135
+ organization.privateMeta.mailDomainActive = true;
136
+ organization.serverMeta.isDNSUnstable = false;
137
+ organization.serverMeta.firstInvalidDNSRecords = undefined;
138
+
139
+ // 4 earlier failures + the one of this run makes the DNS unstable
140
+ organization.serverMeta.lastInvalidDNSDates = [daysAgo(1), daysAgo(2), daysAgo(3), daysAgo(4)];
141
+ await organization.save();
142
+
143
+ await OrganizationDNSService.updateDNSRecords(organization);
144
+
145
+ const emails = await EmailMocker.transactional.getSucceededEmails();
146
+ expect(emails).toHaveLength(1);
147
+ expect(emails[0].subject).toBe(EmailTemplateType.OrganizationUnstableDNS);
148
+ expect(emails[0].bcc).toContain('simon@stamhoofd.be');
149
+
150
+ expect(organization.serverMeta.isDNSUnstable).toBe(true);
151
+ expect(organization.privateMeta.mailDomain).toBe(null);
152
+ expect(organization.privateMeta.pendingMailDomain).toBe('mail.example.com');
153
+ expect(organization.privateMeta.mailDomainActive).toBe(false);
154
+ });
155
+
156
+ test('Sends the invalid DNS email once when the DNS settings break without becoming unstable', async () => {
157
+ validateDNSRecordsMock.mockResolvedValue({ allValid: false, hasAllNonTXT: false });
158
+
159
+ const organization = await createOrganizationWithAdmin();
160
+ organization.privateMeta.mailDomain = 'mail.example.com';
161
+ organization.privateMeta.mailDomainActive = true;
162
+ organization.serverMeta.didSendDomainSetupMail = true;
163
+ organization.serverMeta.DNSRecordWarningCount = 0;
164
+ await organization.save();
165
+
166
+ await OrganizationDNSService.updateDNSRecords(organization);
167
+
168
+ const emails = await EmailMocker.transactional.getSucceededEmails();
169
+ expect(emails).toHaveLength(1);
170
+ expect(emails[0].subject).toBe(EmailTemplateType.OrganizationInvalidDNS);
171
+ expect(emails[0].bcc).toBeUndefined();
172
+ expect(organization.serverMeta.DNSRecordWarningCount).toBe(1);
173
+
174
+ await OrganizationDNSService.updateDNSRecords(organization);
175
+ expect(await EmailMocker.transactional.getSucceededEmails()).toHaveLength(1);
176
+ });
177
+ });