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