@stamhoofd/backend 2.134.0 → 2.136.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 (55) hide show
  1. package/package.json +18 -17
  2. package/src/crons/cleanup-orphaned-cached-balances.test.ts +88 -0
  3. package/src/crons/cleanup-orphaned-cached-balances.ts +44 -0
  4. package/src/crons/index.ts +1 -0
  5. package/src/email-recipient-loaders/orders.ts +7 -9
  6. package/src/endpoints/admin/memberships/GetChargeMembershipsSummaryEndpoint.test.ts +95 -0
  7. package/src/endpoints/admin/memberships/GetChargeMembershipsSummaryEndpoint.ts +7 -0
  8. package/src/endpoints/auth/VerifyEmailEndpoint.test.ts +154 -0
  9. package/src/endpoints/auth/VerifyEmailEndpoint.ts +4 -0
  10. package/src/endpoints/global/email/CreateEmailEndpoint.ts +5 -2
  11. package/src/endpoints/global/email/GetAdminEmailsEndpoint.ts +1 -1
  12. package/src/endpoints/global/email/GetEmailEndpoint.ts +1 -1
  13. package/src/endpoints/global/email/GetUserEmailsEndpoint.test.ts +131 -1
  14. package/src/endpoints/global/email/GetUserEmailsEndpoint.ts +8 -3
  15. package/src/endpoints/global/email/PatchEmailEndpoint.test.ts +348 -1
  16. package/src/endpoints/global/email/PatchEmailEndpoint.ts +21 -2
  17. package/src/endpoints/global/members/GetMembersEndpoint.test.ts +342 -2
  18. package/src/endpoints/global/registration/RegisterMembersEndpoint.test.ts +150 -0
  19. package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +4 -3
  20. package/src/endpoints/organization/dashboard/email-templates/PatchEmailTemplatesEndpoint.test.ts +271 -3
  21. package/src/endpoints/organization/dashboard/email-templates/PatchEmailTemplatesEndpoint.ts +15 -2
  22. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +2 -0
  23. package/src/endpoints/organization/dashboard/organization/SetUitpasClientCredentialsEndpoint.ts +1 -8
  24. package/src/endpoints/organization/dashboard/webshops/GetWebshopOrdersEndpoint.test.ts +112 -0
  25. package/src/endpoints/organization/dashboard/webshops/PatchWebshopEndpoint.test.ts +49 -0
  26. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.test.ts +71 -0
  27. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +4 -0
  28. package/src/endpoints/organization/webshops/GetWebshopEndpoint.test.ts +11 -0
  29. package/src/endpoints/organization/webshops/OrderConfirmationEmailLanguage.test.ts +156 -0
  30. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.test.ts +68 -1
  31. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +8 -0
  32. package/src/excel-loaders/members.test.ts +59 -0
  33. package/src/excel-loaders/members.ts +17 -0
  34. package/src/helpers/AuthenticatedStructures.ts +9 -0
  35. package/src/helpers/GlobalHelper.ts +1 -1
  36. package/src/helpers/MemberMerger.test.ts +70 -2
  37. package/src/helpers/MemberMerger.ts +43 -1
  38. package/src/helpers/MemberUserSyncer.ts +5 -0
  39. package/src/helpers/MembershipCharger.test.ts +110 -0
  40. package/src/helpers/MembershipCharger.ts +12 -33
  41. package/src/helpers/UitpasTokenRepository.ts +12 -11
  42. package/src/helpers/XlsxTransformerColumnHelper.test.ts +63 -0
  43. package/src/helpers/XlsxTransformerColumnHelper.ts +47 -1
  44. package/src/seeds/1783097277-update-member-last-registered.sql +6 -0
  45. package/src/seeds/1783939632-mark-zero-price-payments-succeeded.ts +90 -0
  46. package/src/seeds/1784057557-fix-invisible-trial-registrations.ts +143 -0
  47. package/src/services/BalanceItemService.ts +1 -1
  48. package/src/services/PaymentService.ts +33 -8
  49. package/src/services/PlatformMembershipService.ts +2 -1
  50. package/src/services/RegistrationService.ts +5 -0
  51. package/src/services/uitpas/UitpasService.ts +3 -4
  52. package/src/sql-filters/members.ts +10 -0
  53. package/src/sql-filters/orders.ts +5 -0
  54. package/src/sql-sorters/members.ts +12 -1
  55. package/vitest.config.js +1 -0
@@ -1,10 +1,12 @@
1
1
  import type { PatchableArrayAutoEncoder } from '@simonbackx/simple-encoding';
2
- import { PatchableArray } from '@simonbackx/simple-encoding';
2
+ import { PatchableArray, PatchMap } from '@simonbackx/simple-encoding';
3
3
  import { Request } from '@simonbackx/simple-endpoints';
4
4
  import type { Organization, RegistrationPeriod } from '@stamhoofd/models';
5
5
  import { EmailTemplate, GroupFactory, OrganizationFactory, Platform, RegistrationPeriodFactory, Token, UserFactory } from '@stamhoofd/models';
6
- import { EmailTemplate as EmailTemplateStruct, EmailTemplateType, PermissionLevel, PermissionRoleDetailed, Permissions, PermissionsResourceType, ResourcePermissions, Version } from '@stamhoofd/structures';
7
- import { TestUtils } from '@stamhoofd/test-utils';
6
+ import { EmailContent, EmailTemplate as EmailTemplateStruct, EmailTemplateType, PermissionLevel, PermissionRoleDetailed, Permissions, PermissionsResourceType, ResourcePermissions, Version } from '@stamhoofd/structures';
7
+ import { Language } from '@stamhoofd/types/Language';
8
+ import { STExpect, TestUtils } from '@stamhoofd/test-utils';
9
+ import { v4 as uuidv4 } from 'uuid';
8
10
  import { testServer } from '../../../../../tests/helpers/TestServer.js';
9
11
  import { PatchEmailTemplatesEndpoint } from './PatchEmailTemplatesEndpoint.js';
10
12
 
@@ -87,4 +89,270 @@ describe('Endpoint.PatchEmailTemplatesEndpoint', () => {
87
89
  expect(response.body).toBeDefined();
88
90
  });
89
91
  });
92
+
93
+ describe('Translations', () => {
94
+ const createOrganizationWithAdmin = async () => {
95
+ const organization = await new OrganizationFactory({ period }).create();
96
+ const user = await new UserFactory({
97
+ organization,
98
+ permissions: Permissions.create({
99
+ level: PermissionLevel.Full,
100
+ }),
101
+ }).create();
102
+ const token = await Token.createToken(user);
103
+ return { organization, token };
104
+ };
105
+
106
+ const createTemplate = async (organization: Organization, options: { translations?: Map<Language, EmailContent>; language?: Language } = {}) => {
107
+ const template = new EmailTemplate();
108
+ template.subject = 'Default subject';
109
+ template.type = EmailTemplateType.SavedMembersEmail;
110
+ template.json = {};
111
+ template.html = '<p>Default</p>';
112
+ template.text = 'Default';
113
+ template.organizationId = organization.id;
114
+ if (options.translations) {
115
+ template.translations = options.translations;
116
+ }
117
+ template.language = options.language ?? null;
118
+ await template.save();
119
+ return template;
120
+ };
121
+
122
+ test('a template can be created with a default language and translations', async () => {
123
+ const { organization, token } = await createOrganizationWithAdmin();
124
+
125
+ const body: PatchableArrayAutoEncoder<EmailTemplateStruct> = new PatchableArray();
126
+ body.addPut(EmailTemplateStruct.create({
127
+ id: uuidv4(),
128
+ subject: 'Default subject',
129
+ html: '<p>Default</p>',
130
+ text: 'Default',
131
+ type: EmailTemplateType.SavedMembersEmail,
132
+ language: Language.Dutch,
133
+ translations: new Map([
134
+ [Language.French, EmailContent.create({ subject: 'Sujet français', html: '<p>Français</p>', text: 'Français' })],
135
+ ]),
136
+ }));
137
+
138
+ const response = await patchEmailTemplates(body, token, organization);
139
+ expect(response.body).toHaveLength(1);
140
+ expect(response.body[0].language).toBe(Language.Dutch);
141
+ expect(response.body[0].translations.get(Language.French)!.subject).toBe('Sujet français');
142
+
143
+ const saved = await EmailTemplate.getByID(response.body[0].id);
144
+ expect(saved!.language).toBe(Language.Dutch);
145
+ expect(saved!.translations.get(Language.French)!.subject).toBe('Sujet français');
146
+ });
147
+
148
+ test('the first language can be set without creating a translation', async () => {
149
+ const { organization, token } = await createOrganizationWithAdmin();
150
+ const template = await createTemplate(organization);
151
+
152
+ const body: PatchableArrayAutoEncoder<EmailTemplateStruct> = new PatchableArray();
153
+ body.addPatch(EmailTemplateStruct.patch({
154
+ id: template.id,
155
+ language: Language.French,
156
+ }));
157
+
158
+ await patchEmailTemplates(body, token, organization);
159
+
160
+ const saved = await EmailTemplate.getByID(template.id);
161
+ expect(saved!.language).toBe(Language.French);
162
+ expect(saved!.translations.size).toBe(0);
163
+ expect(saved!.subject).toBe('Default subject');
164
+ });
165
+
166
+ test('a translation can be added to an existing template without changing other content', async () => {
167
+ const { organization, token } = await createOrganizationWithAdmin();
168
+ const template = await createTemplate(organization, {
169
+ language: Language.Dutch,
170
+ translations: new Map([
171
+ [Language.English, EmailContent.create({ subject: 'English subject' })],
172
+ ]),
173
+ });
174
+
175
+ const body: PatchableArrayAutoEncoder<EmailTemplateStruct> = new PatchableArray();
176
+ body.addPatch(EmailTemplateStruct.patch({
177
+ id: template.id,
178
+ translations: new PatchMap([[Language.French, EmailContent.create({ subject: 'Sujet français' })]]),
179
+ }));
180
+
181
+ const response = await patchEmailTemplates(body, token, organization);
182
+ expect(response.body).toHaveLength(1);
183
+
184
+ const saved = await EmailTemplate.getByID(template.id);
185
+ expect(saved!.subject).toBe('Default subject');
186
+ expect(saved!.language).toBe(Language.Dutch);
187
+ expect(saved!.translations.size).toBe(2);
188
+ expect(saved!.translations.get(Language.English)!.subject).toBe('English subject');
189
+ expect(saved!.translations.get(Language.French)!.subject).toBe('Sujet français');
190
+ });
191
+
192
+ test('a translation can be deleted', async () => {
193
+ const { organization, token } = await createOrganizationWithAdmin();
194
+ const template = await createTemplate(organization, {
195
+ language: Language.Dutch,
196
+ translations: new Map([
197
+ [Language.English, EmailContent.create({ subject: 'English subject' })],
198
+ [Language.French, EmailContent.create({ subject: 'Sujet français' })],
199
+ ]),
200
+ });
201
+
202
+ const body: PatchableArrayAutoEncoder<EmailTemplateStruct> = new PatchableArray();
203
+ body.addPatch(EmailTemplateStruct.patch({
204
+ id: template.id,
205
+ translations: new PatchMap([[Language.French, null]]),
206
+ }));
207
+
208
+ await patchEmailTemplates(body, token, organization);
209
+
210
+ const saved = await EmailTemplate.getByID(template.id);
211
+ expect(saved!.language).toBe(Language.Dutch);
212
+ expect(saved!.translations.size).toBe(1);
213
+ expect(saved!.translations.get(Language.French)).toBeUndefined();
214
+ expect(saved!.translations.get(Language.English)!.subject).toBe('English subject');
215
+ });
216
+
217
+ test('removing the default language applies the move-over patch atomically', async () => {
218
+ const { organization, token } = await createOrganizationWithAdmin();
219
+ const template = await createTemplate(organization, {
220
+ language: Language.Dutch,
221
+ translations: new Map([
222
+ [Language.French, EmailContent.create({ subject: 'Sujet français', html: '<p>Français</p>', text: 'Français' })],
223
+ ]),
224
+ });
225
+
226
+ // The UI removes the default language by moving the first remaining translation
227
+ // into the default content in a single patch
228
+ const body: PatchableArrayAutoEncoder<EmailTemplateStruct> = new PatchableArray();
229
+ body.addPatch(EmailTemplateStruct.patch({
230
+ id: template.id,
231
+ language: Language.French,
232
+ subject: 'Sujet français',
233
+ html: '<p>Français</p>',
234
+ text: 'Français',
235
+ translations: new PatchMap([[Language.French, null]]),
236
+ }));
237
+
238
+ await patchEmailTemplates(body, token, organization);
239
+
240
+ const saved = await EmailTemplate.getByID(template.id);
241
+ expect(saved!.language).toBe(Language.French);
242
+ expect(saved!.subject).toBe('Sujet français');
243
+ expect(saved!.html).toBe('<p>Français</p>');
244
+ expect(saved!.translations.size).toBe(0);
245
+ });
246
+
247
+ test('removing the last language keeps the content', async () => {
248
+ const { organization, token } = await createOrganizationWithAdmin();
249
+ const template = await createTemplate(organization, { language: Language.Dutch });
250
+
251
+ const body: PatchableArrayAutoEncoder<EmailTemplateStruct> = new PatchableArray();
252
+ body.addPatch(EmailTemplateStruct.patch({
253
+ id: template.id,
254
+ language: null,
255
+ }));
256
+
257
+ await patchEmailTemplates(body, token, organization);
258
+
259
+ const saved = await EmailTemplate.getByID(template.id);
260
+ expect(saved!.language).toBeNull();
261
+ expect(saved!.subject).toBe('Default subject');
262
+ expect(saved!.html).toBe('<p>Default</p>');
263
+ });
264
+
265
+ test('patching only the subject keeps the translations', async () => {
266
+ const { organization, token } = await createOrganizationWithAdmin();
267
+ const template = await createTemplate(organization, {
268
+ language: Language.Dutch,
269
+ translations: new Map([
270
+ [Language.French, EmailContent.create({ subject: 'Sujet français' })],
271
+ ]),
272
+ });
273
+
274
+ const body: PatchableArrayAutoEncoder<EmailTemplateStruct> = new PatchableArray();
275
+ body.addPatch(EmailTemplateStruct.patch({
276
+ id: template.id,
277
+ subject: 'New default subject',
278
+ }));
279
+
280
+ await patchEmailTemplates(body, token, organization);
281
+
282
+ const saved = await EmailTemplate.getByID(template.id);
283
+ expect(saved!.subject).toBe('New default subject');
284
+ expect(saved!.language).toBe(Language.Dutch);
285
+ expect(saved!.translations.get(Language.French)!.subject).toBe('Sujet français');
286
+ });
287
+
288
+ test('rejects a translation without a default language', async () => {
289
+ const { organization, token } = await createOrganizationWithAdmin();
290
+ const template = await createTemplate(organization);
291
+
292
+ const body: PatchableArrayAutoEncoder<EmailTemplateStruct> = new PatchableArray();
293
+ body.addPatch(EmailTemplateStruct.patch({
294
+ id: template.id,
295
+ translations: new PatchMap([[Language.French, EmailContent.create({ subject: 'Sujet français' })]]),
296
+ }));
297
+
298
+ await expect(patchEmailTemplates(body, token, organization))
299
+ .rejects
300
+ .toThrow(STExpect.errorWithCode('invalid_translations'));
301
+ });
302
+
303
+ test('rejects a translation for the default language itself', async () => {
304
+ const { organization, token } = await createOrganizationWithAdmin();
305
+ const template = await createTemplate(organization, { language: Language.Dutch });
306
+
307
+ const body: PatchableArrayAutoEncoder<EmailTemplateStruct> = new PatchableArray();
308
+ body.addPatch(EmailTemplateStruct.patch({
309
+ id: template.id,
310
+ translations: new PatchMap([[Language.Dutch, EmailContent.create({ subject: 'Nederlands onderwerp' })]]),
311
+ }));
312
+
313
+ await expect(patchEmailTemplates(body, token, organization))
314
+ .rejects
315
+ .toThrow(STExpect.errorWithCode('invalid_translations'));
316
+ });
317
+
318
+ test('rejects clearing the default language while translations remain', async () => {
319
+ const { organization, token } = await createOrganizationWithAdmin();
320
+ const template = await createTemplate(organization, {
321
+ language: Language.Dutch,
322
+ translations: new Map([
323
+ [Language.French, EmailContent.create({ subject: 'Sujet français' })],
324
+ ]),
325
+ });
326
+
327
+ const body: PatchableArrayAutoEncoder<EmailTemplateStruct> = new PatchableArray();
328
+ body.addPatch(EmailTemplateStruct.patch({
329
+ id: template.id,
330
+ language: null,
331
+ }));
332
+
333
+ await expect(patchEmailTemplates(body, token, organization))
334
+ .rejects
335
+ .toThrow(STExpect.errorWithCode('invalid_translations'));
336
+ });
337
+
338
+ test('rejects creating a template with translations but without a default language', async () => {
339
+ const { organization, token } = await createOrganizationWithAdmin();
340
+
341
+ const body: PatchableArrayAutoEncoder<EmailTemplateStruct> = new PatchableArray();
342
+ body.addPut(EmailTemplateStruct.create({
343
+ id: uuidv4(),
344
+ subject: 'Default subject',
345
+ html: '<p>Default</p>',
346
+ text: 'Default',
347
+ type: EmailTemplateType.SavedMembersEmail,
348
+ translations: new Map([
349
+ [Language.French, EmailContent.create({ subject: 'Sujet français' })],
350
+ ]),
351
+ }));
352
+
353
+ await expect(patchEmailTemplates(body, token, organization))
354
+ .rejects
355
+ .toThrow(STExpect.errorWithCode('invalid_translations'));
356
+ });
357
+ });
90
358
  });
@@ -1,9 +1,9 @@
1
1
  import type { AutoEncoderPatchType, Decoder, PatchableArrayAutoEncoder } from '@simonbackx/simple-encoding';
2
- import { PatchableArrayDecoder, StringDecoder } from '@simonbackx/simple-encoding';
2
+ import { PatchableArrayDecoder, patchObject, StringDecoder } from '@simonbackx/simple-encoding';
3
3
  import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
4
4
  import { Endpoint, Response } from '@simonbackx/simple-endpoints';
5
5
  import { EmailTemplate, Group, Platform, Webshop } from '@stamhoofd/models';
6
- import { EmailTemplate as EmailTemplateStruct, PermissionLevel } from '@stamhoofd/structures';
6
+ import { EmailTemplate as EmailTemplateStruct, PermissionLevel, validateEmailTranslations } from '@stamhoofd/structures';
7
7
 
8
8
  import { Context } from '../../../../helpers/Context.js';
9
9
 
@@ -51,6 +51,15 @@ export class PatchEmailTemplatesEndpoint extends Endpoint<Params, Query, Body, R
51
51
  template.subject = patch.subject ?? template.subject;
52
52
  template.text = patch.text ?? template.text;
53
53
  template.json = patch.json ?? template.json;
54
+ template.translations = patchObject(template.translations, patch.translations);
55
+
56
+ if (patch.language !== undefined) {
57
+ template.language = patch.language;
58
+ }
59
+
60
+ if (patch.language !== undefined || patch.translations !== undefined) {
61
+ validateEmailTranslations(template);
62
+ }
54
63
 
55
64
  await template.save();
56
65
 
@@ -94,6 +103,10 @@ export class PatchEmailTemplatesEndpoint extends Endpoint<Params, Query, Body, R
94
103
  template.subject = struct.subject;
95
104
  template.text = struct.text;
96
105
  template.json = struct.json;
106
+ template.translations = struct.translations;
107
+ template.language = struct.language;
108
+
109
+ validateEmailTranslations(template);
97
110
 
98
111
  template.type = struct.type;
99
112
 
@@ -161,6 +161,8 @@ export class PatchOrganizationEndpoint extends Endpoint<Params, Query, Body, Res
161
161
  }
162
162
  organization.privateMeta.privateKey = request.body.privateMeta.privateKey ?? organization.privateMeta.privateKey;
163
163
  organization.privateMeta.featureFlags = patchObject(organization.privateMeta.featureFlags, request.body.privateMeta.featureFlags);
164
+ organization.privateMeta.externalSyncData = patchObject(organization.privateMeta.externalSyncData, request.body.privateMeta.externalSyncData);
165
+ organization.privateMeta.externalGroupNumber = patchObject(organization.privateMeta.externalGroupNumber, request.body.privateMeta.externalGroupNumber);
164
166
  organization.privateMeta.balanceNotificationSettings = patchObject(organization.privateMeta.balanceNotificationSettings, request.body.privateMeta.balanceNotificationSettings);
165
167
  organization.privateMeta.invoiceSettings = patchObject(organization.privateMeta.invoiceSettings, request.body.privateMeta.invoiceSettings);
166
168
  organization.privateMeta.recordAnswers = request.body.privateMeta.recordAnswers.applyTo(organization.privateMeta.recordAnswers);
@@ -84,14 +84,7 @@ export class SetUitpasClientCredentialsEndpoint extends Endpoint<Params, Query,
84
84
 
85
85
  // store the client credentials and store new status in one operation
86
86
  if (!reEvaluation) {
87
- const valid = await UitpasService.storeIfValid(organization.id, request.body.clientId, request.body.clientSecret);
88
- if (!valid) {
89
- throw new SimpleError({
90
- message: 'The provided client credentials are not valid',
91
- code: 'invalid_client_credentials',
92
- human: $t('%1BG'),
93
- });
94
- }
87
+ await UitpasService.storeIfValid(organization.id, request.body.clientId, request.body.clientSecret);
95
88
  }
96
89
  organization.meta.uitpasClientCredentialsStatus = UitpasClientCredentialsStatus.NotChecked;
97
90
  await organization.save();
@@ -0,0 +1,112 @@
1
+ import { Request } from '@simonbackx/simple-endpoints';
2
+ import type { Organization, User, Webshop } from '@stamhoofd/models';
3
+ import { OrderFactory, OrganizationFactory, Token, UserFactory, WebshopFactory } from '@stamhoofd/models';
4
+ import type { PaginatedResponse, PrivateOrder, StamhoofdFilter } from '@stamhoofd/structures';
5
+ import { Cart, Customer, LimitedFilteredRequest, OrderData, PermissionLevel, Permissions, RecordDateAnswer, RecordSettings, RecordType } from '@stamhoofd/structures';
6
+ import { testServer } from '../../../../../tests/helpers/TestServer.js';
7
+ import { GetWebshopOrdersEndpoint } from './GetWebshopOrdersEndpoint.js';
8
+
9
+ describe('Endpoint.GetWebshopOrdersEndpoint', () => {
10
+ const endpoint = new GetWebshopOrdersEndpoint();
11
+
12
+ const getOrders = async ({ filter, organization, user }: { filter: StamhoofdFilter | null; organization: Organization; user: User }) => {
13
+ const token = await Token.createToken(user);
14
+
15
+ const request = Request.get({
16
+ path: '/webshop/orders',
17
+ host: organization.getApiHost(),
18
+ query: new LimitedFilteredRequest({
19
+ filter,
20
+ limit: 100,
21
+ }),
22
+ headers: {
23
+ authorization: 'Bearer ' + token.accessToken,
24
+ },
25
+ });
26
+
27
+ return testServer.test<PaginatedResponse<PrivateOrder[], LimitedFilteredRequest>>(endpoint, request);
28
+ };
29
+
30
+ const createOrderWithDateAnswer = async ({ webshop, record, dateValue }: { webshop: Webshop; record: RecordSettings; dateValue: Date | null }) => {
31
+ const answer = RecordDateAnswer.create({ settings: record });
32
+ answer.dateValue = dateValue;
33
+
34
+ const data = OrderData.create({
35
+ customer: Customer.create({
36
+ firstName: 'John',
37
+ lastName: 'Doe',
38
+ email: 'john.doe@example.com',
39
+ }),
40
+ cart: Cart.create({}),
41
+ });
42
+ data.recordAnswers.set(record.id, answer);
43
+
44
+ return await new OrderFactory({ webshop, data }).create();
45
+ };
46
+
47
+ describe('Filtering on record answers', () => {
48
+ test('filters orders on the date of a date record answer', async () => {
49
+ const organization = await new OrganizationFactory({}).create();
50
+ const user = await new UserFactory({
51
+ organization,
52
+ permissions: Permissions.create({ level: PermissionLevel.Full }),
53
+ }).create();
54
+ const webshop = await new WebshopFactory({ organizationId: organization.id }).create();
55
+
56
+ const record = RecordSettings.create({ type: RecordType.Date });
57
+
58
+ // The order we are looking for: answered with a time of day that is not midnight
59
+ const matchingOrder = await createOrderWithDateAnswer({
60
+ webshop,
61
+ record,
62
+ dateValue: new Date(2023, 5, 10, 14, 30, 15),
63
+ });
64
+
65
+ // Answered with a different date
66
+ await createOrderWithDateAnswer({
67
+ webshop,
68
+ record,
69
+ dateValue: new Date(2023, 5, 11, 14, 30, 15),
70
+ });
71
+
72
+ // Did not answer the question
73
+ await createOrderWithDateAnswer({
74
+ webshop,
75
+ record,
76
+ dateValue: null,
77
+ });
78
+
79
+ // Does not have the record answer at all
80
+ await new OrderFactory({ webshop }).create();
81
+
82
+ const response = await getOrders({
83
+ organization,
84
+ user,
85
+ filter: {
86
+ recordAnswers: {
87
+ [record.id]: {
88
+ // Same filter as the date filter in the UI builds for 'equals'
89
+ $and: [
90
+ {
91
+ dateValue: {
92
+ $gte: new Date(2023, 5, 10),
93
+ },
94
+ },
95
+ {
96
+ dateValue: {
97
+ $lte: new Date(2023, 5, 10, 23, 59, 59, 999),
98
+ },
99
+ },
100
+ ],
101
+ },
102
+ },
103
+ },
104
+ });
105
+
106
+ expect(response.status).toBe(200);
107
+ expect(response.body.results).toIncludeSameMembers([
108
+ expect.objectContaining({ id: matchingOrder.id }),
109
+ ]);
110
+ });
111
+ });
112
+ });
@@ -0,0 +1,49 @@
1
+ import { Request } from '@simonbackx/simple-endpoints';
2
+ import { OrganizationFactory, Token, UserFactory, Webshop, WebshopFactory } from '@stamhoofd/models';
3
+ import { PermissionLevel, Permissions, PrivateWebshop, WebshopMetaData } from '@stamhoofd/structures';
4
+ import { Language } from '@stamhoofd/types/Language';
5
+ import { TestUtils } from '@stamhoofd/test-utils';
6
+
7
+ import { testServer } from '../../../../../tests/helpers/TestServer.js';
8
+ import { PatchWebshopEndpoint } from './PatchWebshopEndpoint.js';
9
+
10
+ describe('Endpoint.PatchWebshop', () => {
11
+ const endpoint = new PatchWebshopEndpoint();
12
+
13
+ beforeEach(async () => {
14
+ TestUtils.setEnvironment('userMode', 'platform');
15
+ });
16
+
17
+ async function createAdminContext() {
18
+ const organization = await new OrganizationFactory({}).create();
19
+ const user = await new UserFactory({
20
+ organization,
21
+ permissions: Permissions.create({
22
+ level: PermissionLevel.Full,
23
+ }),
24
+ }).create();
25
+ const token = await Token.createToken(user);
26
+
27
+ return { organization, token };
28
+ }
29
+
30
+ test('defaultLanguage can be updated', async () => {
31
+ const { organization, token } = await createAdminContext();
32
+ const webshop = await new WebshopFactory({ organizationId: organization.id }).create();
33
+
34
+ const patch = PrivateWebshop.patch({
35
+ meta: WebshopMetaData.patch({
36
+ defaultLanguage: Language.French,
37
+ }),
38
+ });
39
+ const request = Request.buildJson('PATCH', '/webshop/' + webshop.id, organization.getApiHost(), patch);
40
+ request.headers.authorization = 'Bearer ' + token.accessToken;
41
+
42
+ const response = await testServer.test(endpoint, request);
43
+ expect(response.body.meta.defaultLanguage).toBe(Language.French);
44
+
45
+ // Persisted in the database
46
+ const reloaded = await Webshop.getByID(webshop.id);
47
+ expect(reloaded?.meta.defaultLanguage).toBe(Language.French);
48
+ });
49
+ });
@@ -0,0 +1,71 @@
1
+ import type { PatchableArrayAutoEncoder } from '@simonbackx/simple-encoding';
2
+ import { PatchableArray } from '@simonbackx/simple-encoding';
3
+ import { Request } from '@simonbackx/simple-endpoints';
4
+ import { Order, OrganizationFactory, Token, UserFactory, WebshopFactory } from '@stamhoofd/models';
5
+ import { Cart, CartItem, Customer, OrderData, PaymentMethod, PermissionLevel, Permissions, PrivateOrder, Product, ProductPrice, WebshopMetaData } from '@stamhoofd/structures';
6
+ import { TestUtils } from '@stamhoofd/test-utils';
7
+ import { Language } from '@stamhoofd/types/Language';
8
+ import { v4 as uuidv4 } from 'uuid';
9
+
10
+ import { testServer } from '../../../../../tests/helpers/TestServer.js';
11
+ import { PatchWebshopOrdersEndpoint } from './PatchWebshopOrdersEndpoint.js';
12
+
13
+ describe('Endpoint.PatchWebshopOrders', () => {
14
+ const endpoint = new PatchWebshopOrdersEndpoint();
15
+
16
+ beforeEach(async () => {
17
+ TestUtils.setEnvironment('userMode', 'platform');
18
+ });
19
+
20
+ test('Manually created orders use the webshop default language', async () => {
21
+ const organization = await new OrganizationFactory({}).create();
22
+ const user = await new UserFactory({
23
+ organization,
24
+ permissions: Permissions.create({
25
+ level: PermissionLevel.Full,
26
+ }),
27
+ }).create();
28
+ const token = await Token.createToken(user);
29
+
30
+ const freeProductPrice = ProductPrice.create({ name: 'Free', price: 0, stock: 100 });
31
+ const product = Product.create({ name: 'Product', stock: 100, prices: [freeProductPrice] });
32
+
33
+ const webshop = await new WebshopFactory({
34
+ organizationId: organization.id,
35
+ meta: WebshopMetaData.patch({ defaultLanguage: Language.French }),
36
+ products: [product],
37
+ }).create();
38
+
39
+ const orderData = OrderData.create({
40
+ paymentMethod: PaymentMethod.Unknown,
41
+ cart: Cart.create({
42
+ items: [
43
+ CartItem.create({ product, productPrice: freeProductPrice, amount: 1 }),
44
+ ],
45
+ }),
46
+ customer: Customer.create({
47
+ firstName: 'John',
48
+ lastName: 'Doe',
49
+ email: 'john@example.com',
50
+ }),
51
+ });
52
+
53
+ const patchArray: PatchableArrayAutoEncoder<PrivateOrder> = new PatchableArray();
54
+ patchArray.addPut(PrivateOrder.create({
55
+ id: uuidv4(),
56
+ data: orderData,
57
+ webshopId: webshop.id,
58
+ }));
59
+
60
+ const r = Request.buildJson('PATCH', `/webshop/${webshop.id}/orders`, organization.getApiHost(), patchArray);
61
+ r.headers.authorization = 'Bearer ' + token.accessToken;
62
+
63
+ const response = await testServer.test(endpoint, r);
64
+ expect(response.body).toHaveLength(1);
65
+ expect(response.body[0].consumerLanguage).toEqual(Language.French);
66
+
67
+ // Persisted in the dedicated column
68
+ const orderModel = (await Order.getByID(response.body[0].id))!;
69
+ expect(orderModel.consumerLanguage).toEqual(Language.French);
70
+ });
71
+ });
@@ -123,6 +123,10 @@ export class PatchWebshopOrdersEndpoint extends Endpoint<Params, Query, Body, Re
123
123
  model.status = struct.status;
124
124
  model.data = struct.data;
125
125
 
126
+ // Manually created orders have no customer request language, so use the webshop's
127
+ // default language for any future emails to this order.
128
+ model.consumerLanguage = webshop.meta.defaultLanguage;
129
+
126
130
  // For now, we don't invalidate tickets, because they will get invalidated at scan time (the order status is checked)
127
131
  // This allows you to revalidate a ticket without needing to generate a new one (e.g. when accidentally canceling an order)
128
132
  // -> the user doesn't need to download the ticket again
@@ -1,6 +1,7 @@
1
1
  import { Request } from '@simonbackx/simple-endpoints';
2
2
  import { OrganizationFactory, Token, UserFactory, WebshopFactory } from '@stamhoofd/models';
3
3
  import { PermissionLevel, Permissions, WebshopAuthType, WebshopMetaData } from '@stamhoofd/structures';
4
+ import { Language } from '@stamhoofd/types/Language';
4
5
 
5
6
  import { STExpect, TestUtils } from '@stamhoofd/test-utils';
6
7
  import { testServer } from '../../../../tests/helpers/TestServer.js';
@@ -30,6 +31,16 @@ describe('Endpoint.GetWebshop', () => {
30
31
  expect((response.body as any).privateMeta).toBeUndefined();
31
32
  });
32
33
 
34
+ test('Webshop defaults to Dutch as its default language', async () => {
35
+ const organization = await new OrganizationFactory({}).create();
36
+ const webshop = await new WebshopFactory({ organizationId: organization.id }).create();
37
+
38
+ const r = Request.buildJson('GET', '/webshop/' + webshop.id, organization.getApiHost());
39
+
40
+ const response = await testServer.test(endpoint, r);
41
+ expect(response.body.meta.defaultLanguage).toBe(Language.Dutch);
42
+ });
43
+
33
44
  test('Allow access without organization scope', async () => {
34
45
  const organization = await new OrganizationFactory({}).create();
35
46
  const webshop = await new WebshopFactory({ organizationId: organization.id }).create();