@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
@@ -0,0 +1,308 @@
1
+ import { I18n } from '@stamhoofd/backend-i18n';
2
+ import { EmailMocker } from '@stamhoofd/email';
3
+ import type { Organization } from '@stamhoofd/models';
4
+ import { EmailTemplateFactory, Order, OrganizationFactory, Payment, Webshop, WebshopCounter, WebshopFactory } from '@stamhoofd/models';
5
+ import { Cart, CartItem, Customer, EmailContent, EmailTemplateType, OrderData, OrderStatus, PaymentMethod, PaymentStatus, Product, ProductPrice, ProductType, WebshopMetaData, WebshopStatus, WebshopTicketType } from '@stamhoofd/structures';
6
+ import { TestUtils } from '@stamhoofd/test-utils';
7
+ import { Country } from '@stamhoofd/types/Country';
8
+ import { Language } from '@stamhoofd/types/Language';
9
+ import { OrderService } from './OrderService.js';
10
+
11
+ /**
12
+ * The order emails are rendered in the language the customer ordered in. The status name below is
13
+ * rendered through $t inside the order recipient replacements, and is hardcoded on purpose so we
14
+ * don't verify $t with the same $t machinery we're testing.
15
+ * (translations of OrderStatus.Created in shared/locales/dist/locales/digit/{nl,fr}-BE.json)
16
+ */
17
+ const orderStatusName = {
18
+ [Language.Dutch]: 'Nieuw',
19
+ [Language.French]: 'Nouveau',
20
+ };
21
+
22
+ function createCustomer() {
23
+ return Customer.create({
24
+ firstName: 'John',
25
+ lastName: 'Doe',
26
+ email: 'john@example.com',
27
+ });
28
+ }
29
+
30
+ async function createOrder(webshop: Webshop, options: { consumerLanguage?: Language; valid?: boolean; cart?: Cart; paymentMethod?: PaymentMethod } = {}) {
31
+ const order = new Order();
32
+ order.organizationId = webshop.organizationId;
33
+ order.webshopId = webshop.id;
34
+ order.status = OrderStatus.Created;
35
+ order.consumerLanguage = options.consumerLanguage ?? Language.Dutch;
36
+ order.data = OrderData.create({
37
+ paymentMethod: options.paymentMethod ?? PaymentMethod.Transfer,
38
+ customer: createCustomer(),
39
+ cart: options.cart ?? Cart.create({}),
40
+ });
41
+
42
+ if (options.valid ?? true) {
43
+ order.validAt = new Date();
44
+ order.number = await WebshopCounter.getNextNumber(webshop);
45
+ }
46
+
47
+ await order.save();
48
+ return order;
49
+ }
50
+
51
+ function loadRelations(order: Order, webshop: Webshop, organization: Organization) {
52
+ return order.setRelation(Order.webshop, webshop.setRelation(Webshop.organization, organization));
53
+ }
54
+
55
+ async function createTransferPayment(organization: Organization) {
56
+ const payment = new Payment();
57
+ payment.organizationId = organization.id;
58
+ payment.method = PaymentMethod.Transfer;
59
+ payment.status = PaymentStatus.Succeeded;
60
+ payment.price = 1000;
61
+ payment.paidAt = new Date();
62
+ await payment.save();
63
+ return payment;
64
+ }
65
+
66
+ describe('OrderService', () => {
67
+ describe('sendEmailTemplate', () => {
68
+ test('renders the email in the language the customer ordered in, not the ambient language', async () => {
69
+ // Make French a valid locale so its real translations (loaded from disk) are actually used.
70
+ TestUtils.setEnvironment('locales', { [Country.Belgium]: [Language.Dutch, Language.French] });
71
+
72
+ const organization = await new OrganizationFactory({}).create();
73
+ const webshop = await new WebshopFactory({ organizationId: organization.id }).create();
74
+
75
+ await new EmailTemplateFactory({
76
+ organization,
77
+ webshopId: webshop.id,
78
+ type: EmailTemplateType.OrderConfirmationOnline,
79
+ subject: 'Bevestiging',
80
+ html: '<p>{{orderStatus}}</p>',
81
+ text: '{{orderStatus}}',
82
+ }).create();
83
+
84
+ const order = await createOrder(webshop, { consumerLanguage: Language.French });
85
+ const loadedOrder = loadRelations(order, webshop, organization);
86
+
87
+ // The ambient language is Dutch while the customer ordered in French
88
+ await I18n.runWithLocale(new I18n(Language.Dutch, Country.Belgium), async () => {
89
+ await OrderService.sendEmailTemplate(loadedOrder, {
90
+ type: EmailTemplateType.OrderConfirmationOnline,
91
+ });
92
+ });
93
+
94
+ const emails = await EmailMocker.transactional.getSucceededEmails();
95
+ expect(emails).toHaveLength(1);
96
+ expect(emails[0].html).toContain(orderStatusName[Language.French]);
97
+ expect(emails[0].html).not.toContain(orderStatusName[Language.Dutch]);
98
+ });
99
+
100
+ test('does not send anything for an archived webshop', async () => {
101
+ const organization = await new OrganizationFactory({}).create();
102
+ const webshop = await new WebshopFactory({
103
+ organizationId: organization.id,
104
+ meta: WebshopMetaData.patch({ status: WebshopStatus.Archived }),
105
+ }).create();
106
+
107
+ await new EmailTemplateFactory({
108
+ organization,
109
+ webshopId: webshop.id,
110
+ type: EmailTemplateType.OrderConfirmationOnline,
111
+ subject: 'Bevestiging',
112
+ html: '<p>Bevestiging</p>',
113
+ text: 'Bevestiging',
114
+ }).create();
115
+
116
+ const order = await createOrder(webshop);
117
+ const loadedOrder = loadRelations(order, webshop, organization);
118
+
119
+ await OrderService.sendEmailTemplate(loadedOrder, {
120
+ type: EmailTemplateType.OrderConfirmationOnline,
121
+ });
122
+
123
+ expect(await EmailMocker.transactional.getSucceededEmails()).toHaveLength(0);
124
+ });
125
+ });
126
+
127
+ describe('markValid', () => {
128
+ test('sends the notification email without the customer name and in the webshop language', async () => {
129
+ TestUtils.setEnvironment('locales', { [Country.Belgium]: [Language.Dutch, Language.French] });
130
+
131
+ const organization = await new OrganizationFactory({}).create();
132
+ const webshop = await new WebshopFactory({
133
+ organizationId: organization.id,
134
+ meta: WebshopMetaData.patch({ defaultLanguage: Language.French }),
135
+ }).create();
136
+ webshop.privateMeta.notificationEmails = ['admin@example.com'];
137
+ await webshop.save();
138
+
139
+ await new EmailTemplateFactory({
140
+ organization,
141
+ webshopId: webshop.id,
142
+ type: EmailTemplateType.OrderConfirmationOnline,
143
+ subject: 'Bevestiging',
144
+ html: '<p>Bevestiging</p>',
145
+ text: 'Bevestiging',
146
+ }).create();
147
+
148
+ // Default (Dutch) content + a French translation, so we can tell which language was selected
149
+ await new EmailTemplateFactory({
150
+ organization,
151
+ webshopId: webshop.id,
152
+ type: EmailTemplateType.OrderNotification,
153
+ subject: 'Nederlandse melding',
154
+ html: '<p>NL [{{firstName}}][{{lastName}}] {{orderUrl}}</p>',
155
+ text: 'Nederlandse melding',
156
+ language: Language.Dutch,
157
+ translations: new Map([
158
+ [Language.French, EmailContent.create({
159
+ subject: 'Franse melding',
160
+ html: '<p>FR [{{firstName}}][{{lastName}}] {{orderUrl}}</p>',
161
+ text: 'Franse melding',
162
+ })],
163
+ ]),
164
+ }).create();
165
+
166
+ // The customer ordered in Dutch, the webshop default language is French
167
+ const order = await createOrder(webshop, { consumerLanguage: Language.Dutch, valid: false });
168
+ const loadedOrder = loadRelations(order, webshop, organization);
169
+
170
+ await OrderService.markValid(loadedOrder, null, []);
171
+
172
+ const emails = await EmailMocker.transactional.getSucceededEmails();
173
+ expect(emails).toHaveLength(2);
174
+
175
+ const customerEmail = emails.find(e => e.to.includes('john@example.com'));
176
+ const notification = emails.find(e => e.to.includes('admin@example.com'));
177
+ expect(customerEmail).toBeDefined();
178
+ expect(notification).toBeDefined();
179
+
180
+ // The customer name is cleared, so the notification is not addressed to the customer
181
+ expect(notification!.to).toBe('admin@example.com');
182
+
183
+ // The firstName/lastName replacements are dropped, so the customer name doesn't leak
184
+ expect(notification!.html).toContain('[][]');
185
+ expect(notification!.html).not.toContain('John');
186
+ expect(notification!.html).not.toContain('Doe');
187
+
188
+ // The language falls back to the webshop default (French), not the customer's (Dutch)
189
+ expect(notification!.subject).toBe('Franse melding');
190
+ expect(notification!.html).toContain('FR ');
191
+
192
+ // The orderUrl of the notification points at the dashboard, not at the webshop order page
193
+ expect(notification!.html).toContain('/webshops/');
194
+
195
+ // The customer still receives their own confirmation
196
+ expect(customerEmail!.subject).toBe('Bevestiging');
197
+ });
198
+ });
199
+
200
+ describe('markPaid', () => {
201
+ test('sends the tickets email when tickets were created', async () => {
202
+ const organization = await new OrganizationFactory({}).create();
203
+ const productPrice = ProductPrice.create({ name: 'Standaard', price: 0 });
204
+ const ticketProduct = Product.create({
205
+ name: 'Ticket',
206
+ type: ProductType.Ticket,
207
+ prices: [productPrice],
208
+ });
209
+
210
+ const webshop = await new WebshopFactory({
211
+ organizationId: organization.id,
212
+ meta: WebshopMetaData.patch({ ticketType: WebshopTicketType.Tickets }),
213
+ products: [ticketProduct],
214
+ }).create();
215
+
216
+ await new EmailTemplateFactory({
217
+ organization,
218
+ webshopId: webshop.id,
219
+ type: EmailTemplateType.TicketsReceivedTransfer,
220
+ subject: 'Tickets ontvangen',
221
+ html: '<p>Tickets ontvangen</p>',
222
+ text: 'Tickets ontvangen',
223
+ }).create();
224
+
225
+ await new EmailTemplateFactory({
226
+ organization,
227
+ webshopId: webshop.id,
228
+ type: EmailTemplateType.OrderReceivedTransfer,
229
+ subject: 'Betaling ontvangen',
230
+ html: '<p>Betaling ontvangen</p>',
231
+ text: 'Betaling ontvangen',
232
+ }).create();
233
+
234
+ const order = await createOrder(webshop, {
235
+ cart: Cart.create({
236
+ items: [CartItem.create({ product: ticketProduct, productPrice, amount: 1 })],
237
+ }),
238
+ });
239
+
240
+ const payment = await createTransferPayment(organization);
241
+ await OrderService.markPaid(order, payment, organization, webshop);
242
+
243
+ const emails = await EmailMocker.transactional.getSucceededEmails();
244
+ expect(emails).toHaveLength(1);
245
+ expect(emails[0].subject).toBe('Tickets ontvangen');
246
+ });
247
+
248
+ test('sends the paid email for a transfer payment when no tickets were created', async () => {
249
+ const organization = await new OrganizationFactory({}).create();
250
+ const webshop = await new WebshopFactory({ organizationId: organization.id }).create();
251
+
252
+ await new EmailTemplateFactory({
253
+ organization,
254
+ webshopId: webshop.id,
255
+ type: EmailTemplateType.TicketsReceivedTransfer,
256
+ subject: 'Tickets ontvangen',
257
+ html: '<p>Tickets ontvangen</p>',
258
+ text: 'Tickets ontvangen',
259
+ }).create();
260
+
261
+ await new EmailTemplateFactory({
262
+ organization,
263
+ webshopId: webshop.id,
264
+ type: EmailTemplateType.OrderReceivedTransfer,
265
+ subject: 'Betaling ontvangen',
266
+ html: '<p>Betaling ontvangen</p>',
267
+ text: 'Betaling ontvangen',
268
+ }).create();
269
+
270
+ const order = await createOrder(webshop);
271
+ const payment = await createTransferPayment(organization);
272
+
273
+ await OrderService.markPaid(order, payment, organization, webshop);
274
+
275
+ const emails = await EmailMocker.transactional.getSucceededEmails();
276
+ expect(emails).toHaveLength(1);
277
+ expect(emails[0].subject).toBe('Betaling ontvangen');
278
+ });
279
+
280
+ test('does not send the paid email for a non-transfer payment when no tickets were created', async () => {
281
+ const organization = await new OrganizationFactory({}).create();
282
+ const webshop = await new WebshopFactory({ organizationId: organization.id }).create();
283
+
284
+ await new EmailTemplateFactory({
285
+ organization,
286
+ webshopId: webshop.id,
287
+ type: EmailTemplateType.OrderReceivedTransfer,
288
+ subject: 'Betaling ontvangen',
289
+ html: '<p>Betaling ontvangen</p>',
290
+ text: 'Betaling ontvangen',
291
+ }).create();
292
+
293
+ const order = await createOrder(webshop, { paymentMethod: PaymentMethod.PointOfSale });
294
+
295
+ const payment = new Payment();
296
+ payment.organizationId = organization.id;
297
+ payment.method = PaymentMethod.PointOfSale;
298
+ payment.status = PaymentStatus.Succeeded;
299
+ payment.price = 1000;
300
+ payment.paidAt = new Date();
301
+ await payment.save();
302
+
303
+ await OrderService.markPaid(order, payment, organization, webshop);
304
+
305
+ expect(await EmailMocker.transactional.getSucceededEmails()).toHaveLength(0);
306
+ });
307
+ });
308
+ });
@@ -0,0 +1,214 @@
1
+ import { I18n } from '@stamhoofd/backend-i18n';
2
+ import type { Organization, Payment, Ticket } from '@stamhoofd/models';
3
+ import { Order, sendEmailTemplate, Webshop, WebshopCounter } from '@stamhoofd/models';
4
+ import { EmailTemplateType, OrderStatus, PaymentMethod, Recipient, Replacement, WebshopPreview, WebshopStatus, WebshopTicketType } from '@stamhoofd/structures';
5
+ import { Formatter } from '@stamhoofd/utility';
6
+
7
+ /**
8
+ * An order with its webshop, and the organization of that webshop, loaded.
9
+ */
10
+ export type OrderWithWebshop = Order & { webshop: Webshop & { organization: Organization } };
11
+
12
+ /**
13
+ * The lifecycle transitions of an order (valid / paid) and the customer emails they send.
14
+ */
15
+ export class OrderService {
16
+ /**
17
+ * Only call this once! Make sure you use the queues correctly
18
+ */
19
+ static async markPaid(order: Order, payment: Payment | null, organization: Organization, knownWebshop?: Webshop) {
20
+ if (!order.id) {
21
+ await order.save();
22
+ }
23
+ console.log('Marking order ' + order.id + ' as paid');
24
+
25
+ const webshop = (knownWebshop ?? (await Webshop.getByID(order.webshopId)))?.setRelation(Webshop.organization, organization);
26
+ if (!webshop) {
27
+ console.error('Missing webshop for order ' + order.id);
28
+ return;
29
+ }
30
+
31
+ if (order.status === OrderStatus.Deleted) {
32
+ await order.undoPaymentFailed(payment, organization);
33
+ }
34
+
35
+ const loadedOrder = order.setRelation(Order.webshop, webshop);
36
+ const { tickets, didCreateTickets } = await loadedOrder.updateTickets({ hasPaidPayment: true });
37
+
38
+ // Needs to happen before validation, because we can include the tickets in the validation that way
39
+ if (order.validAt === null) {
40
+ await this.markValid(loadedOrder, payment, tickets);
41
+ } else {
42
+ order.markUpdated();
43
+ await order.save();
44
+
45
+ if (!order.data.shouldSendPaymentUpdates) {
46
+ console.log('Skip sending paid email for order ' + order.id);
47
+ return;
48
+ }
49
+ if (order.data.customer.email.length > 0) {
50
+ if (didCreateTickets) {
51
+ await this.sendTickets(loadedOrder);
52
+ } else {
53
+ if (payment && payment.method === PaymentMethod.Transfer) {
54
+ await this.sendPaidMail(loadedOrder);
55
+ }
56
+ }
57
+ }
58
+ }
59
+ }
60
+
61
+ /**
62
+ * WARNING: this should always run inside a queue so it only runs once for the same order
63
+ * Include any tickets that are generated and should be included in the e-mail
64
+ */
65
+ static async markValid(order: OrderWithWebshop, payment: Payment | null, tickets: Ticket[]) {
66
+ const webshop = order.webshop;
67
+ const organization = webshop.organization;
68
+
69
+ console.log('Marking as valid: order ' + order.id);
70
+ const wasValid = order.validAt !== null;
71
+
72
+ if (wasValid) {
73
+ console.warn('Warning: already validated an order');
74
+ return;
75
+ }
76
+ order.validAt = new Date(); // will get flattened AFTER calculations
77
+ order.validAt.setMilliseconds(0);
78
+ order.number = await WebshopCounter.getNextNumber(webshop);
79
+
80
+ if (payment && !Order.payment.isLoaded(order)) {
81
+ order.setRelation(Order.payment, payment);
82
+ }
83
+
84
+ // Now we have a number, update the payment
85
+ if (payment && payment.method === PaymentMethod.Transfer) {
86
+ // Only now we can update the transfer description, since we need the order number as a reference
87
+ payment.transferSettings = order.getTransferSettings({ shouldThrowIfNoIban: true });
88
+ payment.generateDescription(organization, order.number.toString(), order.getTransferReplacements());
89
+ await payment.save();
90
+ }
91
+
92
+ await order.save();
93
+
94
+ if (order.data.customer.email.length > 0) {
95
+ if (tickets.length > 0) {
96
+ // Also send a copy
97
+ if (payment && payment.method === PaymentMethod.PointOfSale) {
98
+ await this.sendEmailTemplate(order, {
99
+ type: EmailTemplateType.TicketsConfirmationPOS,
100
+ });
101
+ } else {
102
+ await this.sendEmailTemplate(order, {
103
+ type: EmailTemplateType.TicketsConfirmation,
104
+ });
105
+ }
106
+ } else {
107
+ if (webshop.meta.ticketType === WebshopTicketType.None) {
108
+ if (payment && payment.method === PaymentMethod.Transfer) {
109
+ // Also send a copy
110
+ await this.sendEmailTemplate(order, {
111
+ type: EmailTemplateType.OrderConfirmationTransfer,
112
+ });
113
+ } else if (payment && payment.method === PaymentMethod.PointOfSale) {
114
+ await this.sendEmailTemplate(order, {
115
+ type: EmailTemplateType.OrderConfirmationPOS,
116
+ });
117
+ } else {
118
+ // Also send a copy
119
+ await this.sendEmailTemplate(order, {
120
+ type: EmailTemplateType.OrderConfirmationOnline,
121
+ });
122
+ }
123
+ } else {
124
+ if (payment && payment.method === PaymentMethod.Transfer) {
125
+ await this.sendEmailTemplate(order, {
126
+ type: EmailTemplateType.TicketsConfirmationTransfer,
127
+ });
128
+ } else {
129
+ console.error('Unexpected missing tickets for order where tickets are expected');
130
+ }
131
+ }
132
+ }
133
+ }
134
+
135
+ if (webshop.privateMeta.notificationEmails) {
136
+ const i18n = organization.i18n;
137
+
138
+ const webshopDashboardUrl = 'https://' + (STAMHOOFD.domains.dashboard ?? 'stamhoofd.app') + '/' + i18n.locale + '/webshops/' + Formatter.slug(webshop.meta.name) + '/orders';
139
+
140
+ // Send an email to all these notification emails
141
+ for (const email of webshop.privateMeta.notificationEmails) {
142
+ await this.sendEmailTemplate(order, {
143
+ type: EmailTemplateType.OrderNotification,
144
+ to: Recipient.create({
145
+ email,
146
+ replacements: [
147
+ Replacement.create({
148
+ token: 'orderUrl',
149
+ value: webshopDashboardUrl,
150
+ }),
151
+ ],
152
+ }),
153
+ });
154
+ }
155
+ }
156
+ }
157
+
158
+ static async sendPaidMail(order: OrderWithWebshop) {
159
+ // For a tickets webshop, where the order was marked as paid / non-paid, we should still send the tickets email
160
+ // - because the normal email is not editable
161
+ const hasTickets = order.webshop.meta.hasTickets;
162
+
163
+ await this.sendEmailTemplate(order, {
164
+ type: hasTickets ? EmailTemplateType.TicketsReceivedTransfer : EmailTemplateType.OrderReceivedTransfer,
165
+ });
166
+ }
167
+
168
+ static async sendTickets(order: OrderWithWebshop) {
169
+ await this.sendEmailTemplate(order, {
170
+ type: EmailTemplateType.TicketsReceivedTransfer,
171
+ });
172
+ }
173
+
174
+ static async sendEmailTemplate(order: OrderWithWebshop, data: {
175
+ type: EmailTemplateType;
176
+ to?: Recipient;
177
+ }) {
178
+ // Never send an email for archived webshops
179
+ if (order.webshop.meta.status === WebshopStatus.Archived) {
180
+ return;
181
+ }
182
+
183
+ // Render all $t's (recipient replacements, order tables, template...) in the language
184
+ // the customer used while placing the order, instead of the current request language.
185
+ const i18n = new I18n(order.consumerLanguage, order.webshop.organization.address.country);
186
+
187
+ await I18n.runWithLocale(i18n, async () => {
188
+ let recipient = (await order.getStructure()).getRecipient(
189
+ order.webshop.organization.getBaseStructure(),
190
+ WebshopPreview.create(order.webshop),
191
+ );
192
+
193
+ if (data.to) {
194
+ // Clear first and last name
195
+ recipient.firstName = null;
196
+ recipient.lastName = null;
197
+ recipient.language = order.webshop.meta.defaultLanguage;
198
+ recipient.replacements = recipient.replacements.filter(r => !['firstName', 'lastName'].includes(r.token));
199
+ data.to.merge(recipient);
200
+ recipient = data.to;
201
+ }
202
+
203
+ // Create e-mail builder
204
+ await sendEmailTemplate(order.webshop.organization, {
205
+ recipients: [recipient],
206
+ template: {
207
+ type: data.type,
208
+ webshop: order.webshop,
209
+ },
210
+ type: 'transactional',
211
+ });
212
+ });
213
+ }
214
+ }