@stamhoofd/backend 2.135.0 → 2.136.1
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 +18 -17
- package/src/crons/cleanup-orphaned-cached-balances.test.ts +88 -0
- package/src/crons/cleanup-orphaned-cached-balances.ts +44 -0
- package/src/crons/index.ts +1 -0
- package/src/email-recipient-loaders/orders.ts +7 -9
- package/src/endpoints/admin/memberships/GetChargeMembershipsSummaryEndpoint.test.ts +95 -0
- package/src/endpoints/admin/memberships/GetChargeMembershipsSummaryEndpoint.ts +7 -0
- package/src/endpoints/global/email/CreateEmailEndpoint.ts +5 -2
- package/src/endpoints/global/email/GetAdminEmailsEndpoint.ts +1 -1
- package/src/endpoints/global/email/GetEmailEndpoint.ts +1 -1
- package/src/endpoints/global/email/GetUserEmailsEndpoint.test.ts +131 -1
- package/src/endpoints/global/email/GetUserEmailsEndpoint.ts +8 -3
- package/src/endpoints/global/email/PatchEmailEndpoint.test.ts +287 -1
- package/src/endpoints/global/email/PatchEmailEndpoint.ts +21 -2
- package/src/endpoints/global/members/GetMembersEndpoint.test.ts +119 -1
- package/src/endpoints/organization/dashboard/email-templates/PatchEmailTemplatesEndpoint.test.ts +271 -3
- package/src/endpoints/organization/dashboard/email-templates/PatchEmailTemplatesEndpoint.ts +15 -2
- package/src/endpoints/organization/dashboard/webshops/GetWebshopOrdersEndpoint.test.ts +112 -0
- package/src/endpoints/organization/webshops/OrderConfirmationEmailLanguage.test.ts +87 -1
- package/src/excel-loaders/members.test.ts +59 -0
- package/src/excel-loaders/members.ts +17 -0
- package/src/helpers/AuthenticatedStructures.ts +9 -0
- package/src/helpers/MemberMerger.test.ts +70 -2
- package/src/helpers/MemberMerger.ts +43 -1
- package/src/helpers/MemberUserSyncer.ts +5 -0
- package/src/helpers/MembershipCharger.test.ts +110 -0
- package/src/helpers/MembershipCharger.ts +10 -32
- package/src/helpers/XlsxTransformerColumnHelper.test.ts +63 -0
- package/src/helpers/XlsxTransformerColumnHelper.ts +47 -1
- package/src/seeds/1784057557-fix-invisible-trial-registrations.ts +143 -0
- package/src/services/BalanceItemService.ts +1 -1
- package/src/sql-filters/members.ts +5 -0
- package/src/sql-filters/orders.ts +5 -0
- package/vitest.config.js +1 -0
package/src/endpoints/organization/dashboard/email-templates/PatchEmailTemplatesEndpoint.test.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
|
@@ -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
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Request } from '@simonbackx/simple-endpoints';
|
|
2
2
|
import { EmailMocker } from '@stamhoofd/email';
|
|
3
3
|
import { EmailTemplateFactory, OrganizationFactory, WebshopFactory } from '@stamhoofd/models';
|
|
4
|
-
import { Cart, CartItem, Customer, EmailTemplateType, OrderData, PaymentMethod, Product, ProductPrice } from '@stamhoofd/structures';
|
|
4
|
+
import { Cart, CartItem, Customer, EmailContent, EmailTemplateType, OrderData, PaymentMethod, Product, ProductPrice } from '@stamhoofd/structures';
|
|
5
5
|
import { TestUtils } from '@stamhoofd/test-utils';
|
|
6
6
|
import { Country } from '@stamhoofd/types/Country';
|
|
7
7
|
import { Language } from '@stamhoofd/types/Language';
|
|
@@ -67,4 +67,90 @@ describe('Order confirmation email language', () => {
|
|
|
67
67
|
expect(dutchEmail.html).toContain(dutchStatusName);
|
|
68
68
|
expect(dutchEmail.html).not.toContain(frenchStatusName);
|
|
69
69
|
});
|
|
70
|
+
|
|
71
|
+
test('selects the translated template that matches the language the customer ordered in', async () => {
|
|
72
|
+
TestUtils.setEnvironment('locales', { [Country.Belgium]: [Language.Dutch, Language.French] });
|
|
73
|
+
|
|
74
|
+
const organization = await new OrganizationFactory({}).create();
|
|
75
|
+
const freeProductPrice = ProductPrice.create({ name: 'Free', price: 0, stock: 100 });
|
|
76
|
+
const product = Product.create({ name: 'Product', stock: 100, prices: [freeProductPrice] });
|
|
77
|
+
const webshop = await new WebshopFactory({
|
|
78
|
+
organizationId: organization.id,
|
|
79
|
+
products: [product],
|
|
80
|
+
}).create();
|
|
81
|
+
|
|
82
|
+
// The template has default (Dutch) content and a French translation. The French translation
|
|
83
|
+
// must be selected for a French order, the default content for a Dutch order.
|
|
84
|
+
// Both order-table replacements are included: their headers/titles are localized per
|
|
85
|
+
// recipient (consumer) language, independently of the selected template translation.
|
|
86
|
+
const tables = '{{orderTable}} {{orderDetailsTable}}';
|
|
87
|
+
await new EmailTemplateFactory({
|
|
88
|
+
organization,
|
|
89
|
+
webshopId: webshop.id,
|
|
90
|
+
type: EmailTemplateType.OrderConfirmationOnline,
|
|
91
|
+
subject: 'Standaard onderwerp',
|
|
92
|
+
html: `<p>Standaard inhoud ${tables}</p>`,
|
|
93
|
+
text: 'Standaard inhoud',
|
|
94
|
+
language: Language.Dutch,
|
|
95
|
+
translations: new Map([
|
|
96
|
+
[Language.French, EmailContent.create({ subject: 'Sujet français', html: `<p>Contenu français ${tables}</p>`, text: 'Contenu français' })],
|
|
97
|
+
]),
|
|
98
|
+
}).create();
|
|
99
|
+
|
|
100
|
+
// Table headers/titles rendered via $t inside the order tables. Hardcoded on purpose so we
|
|
101
|
+
// don't verify $t with the same $t machinery we're testing.
|
|
102
|
+
// orderTable column headers (%Sc / %M4) and an orderDetailsTable row title (%xA).
|
|
103
|
+
const dutch = { orderColumn: 'Artikel', amountColumn: 'Aantal', orderNumberTitle: 'Bestelnummer' };
|
|
104
|
+
const french = { orderColumn: 'Article', amountColumn: 'Nombre', orderNumberTitle: 'Numéro de commande' };
|
|
105
|
+
|
|
106
|
+
const placeFreeOrderInLanguage = async (language: string) => {
|
|
107
|
+
EmailMocker.transactional.reset();
|
|
108
|
+
|
|
109
|
+
const orderData = OrderData.create({
|
|
110
|
+
paymentMethod: PaymentMethod.Unknown,
|
|
111
|
+
cart: Cart.create({
|
|
112
|
+
items: [CartItem.create({ product, productPrice: freeProductPrice, amount: 1 })],
|
|
113
|
+
}),
|
|
114
|
+
customer: Customer.create({ firstName: 'John', lastName: 'Doe', email: 'john@example.com', phone: '+32412345678' }),
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const r = Request.buildJson('POST', `/webshop/${webshop.id}/order`, organization.getApiHost(), orderData);
|
|
118
|
+
r.headers['accept-language'] = language;
|
|
119
|
+
await testServer.test(endpoint, r);
|
|
120
|
+
|
|
121
|
+
const emails = await EmailMocker.transactional.getSucceededEmails();
|
|
122
|
+
expect(emails).toHaveLength(1);
|
|
123
|
+
return emails[0];
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// French order → the French translation of the template is used
|
|
127
|
+
const frenchEmail = await placeFreeOrderInLanguage('fr');
|
|
128
|
+
expect(frenchEmail.subject).toBe('Sujet français');
|
|
129
|
+
expect(frenchEmail.html).toContain('Contenu français');
|
|
130
|
+
expect(frenchEmail.html).not.toContain('Standaard inhoud');
|
|
131
|
+
|
|
132
|
+
// Both order tables are rendered in the recipient's (consumer) language, French
|
|
133
|
+
expect(frenchEmail.html).toContain(french.orderColumn);
|
|
134
|
+
expect(frenchEmail.html).toContain(french.amountColumn);
|
|
135
|
+
expect(frenchEmail.html).toContain(french.orderNumberTitle);
|
|
136
|
+
// No Dutch table headers/titles leak into the French email
|
|
137
|
+
expect(frenchEmail.html).not.toContain(dutch.orderColumn);
|
|
138
|
+
expect(frenchEmail.html).not.toContain(dutch.amountColumn);
|
|
139
|
+
expect(frenchEmail.html).not.toContain(dutch.orderNumberTitle);
|
|
140
|
+
|
|
141
|
+
// Dutch order → Dutch is the default language, so the default content is used
|
|
142
|
+
const dutchEmail = await placeFreeOrderInLanguage('nl');
|
|
143
|
+
expect(dutchEmail.subject).toBe('Standaard onderwerp');
|
|
144
|
+
expect(dutchEmail.html).toContain('Standaard inhoud');
|
|
145
|
+
expect(dutchEmail.html).not.toContain('Contenu français');
|
|
146
|
+
|
|
147
|
+
// Both order tables are rendered in Dutch
|
|
148
|
+
expect(dutchEmail.html).toContain(dutch.orderColumn);
|
|
149
|
+
expect(dutchEmail.html).toContain(dutch.amountColumn);
|
|
150
|
+
expect(dutchEmail.html).toContain(dutch.orderNumberTitle);
|
|
151
|
+
// No French table headers/titles leak into the Dutch email
|
|
152
|
+
expect(dutchEmail.html).not.toContain(french.orderColumn);
|
|
153
|
+
expect(dutchEmail.html).not.toContain(french.amountColumn);
|
|
154
|
+
expect(dutchEmail.html).not.toContain(french.orderNumberTitle);
|
|
155
|
+
});
|
|
70
156
|
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { XlsxTransformerConcreteColumn } from '@stamhoofd/excel-writer';
|
|
2
|
+
import type { PlatformMember } from '@stamhoofd/structures';
|
|
3
|
+
import { EmergencyContact, MemberDetails, MembersBlob, MemberWithRegistrationsBlob, Organization, Platform, PlatformFamily } from '@stamhoofd/structures';
|
|
4
|
+
import { baseMemberColumns } from './members.js';
|
|
5
|
+
|
|
6
|
+
describe('Member excel export', () => {
|
|
7
|
+
describe('emergencyContacts column', () => {
|
|
8
|
+
function createMember(emergencyContacts: EmergencyContact[]) {
|
|
9
|
+
const organization = Organization.create({});
|
|
10
|
+
const blob = MembersBlob.create({
|
|
11
|
+
organizations: [organization],
|
|
12
|
+
members: [
|
|
13
|
+
MemberWithRegistrationsBlob.create({
|
|
14
|
+
details: MemberDetails.create({ firstName: 'John', lastName: 'Doe', emergencyContacts }),
|
|
15
|
+
}),
|
|
16
|
+
],
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const family = PlatformFamily.create(blob, { platform: Platform.create({}), contextOrganization: organization });
|
|
20
|
+
return family.members[0];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getColumn() {
|
|
24
|
+
const column = baseMemberColumns.find(c => 'id' in c && c.id === 'emergencyContacts') as XlsxTransformerConcreteColumn<PlatformMember> | undefined;
|
|
25
|
+
|
|
26
|
+
if (!column) {
|
|
27
|
+
throw new Error('Column emergencyContacts not found');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return column;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getValue(member: PlatformMember) {
|
|
34
|
+
return getColumn().getValue(member).value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
it('lists every emergency contact on its own line, also the ones without a numbered column', () => {
|
|
38
|
+
const member = createMember([
|
|
39
|
+
EmergencyContact.create({ name: 'An Peeters', title: 'Oma', phone: '0470 12 34 56' }),
|
|
40
|
+
EmergencyContact.create({ name: 'Jan Janssens', title: 'Buur', phone: '0470 65 43 21' }),
|
|
41
|
+
EmergencyContact.create({ name: 'Rita Maes', title: 'Tante', phone: '0470 11 22 33' }),
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
expect(getValue(member)).toBe('Oma: An Peeters (0470 12 34 56)\nBuur: Jan Janssens (0470 65 43 21)\nTante: Rita Maes (0470 11 22 33)');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('wraps the text so every line stays visible', () => {
|
|
48
|
+
const member = createMember([
|
|
49
|
+
EmergencyContact.create({ name: 'An Peeters', title: 'Oma', phone: '0470 12 34 56' }),
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
expect(getColumn().getValue(member).style?.alignment?.wrapText).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('returns an empty value when the member has no emergency contacts', () => {
|
|
56
|
+
expect(getValue(createMember([]))).toBe('');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -191,6 +191,23 @@ export const baseMemberColumns: XlsxTransformerColumn<PlatformMember>[] = [
|
|
|
191
191
|
|
|
192
192
|
...XlsxTransformerColumnHelper.creatColumnsForParents(),
|
|
193
193
|
|
|
194
|
+
// emergency contacts
|
|
195
|
+
{
|
|
196
|
+
id: 'emergencyContacts',
|
|
197
|
+
name: $t(`%ZeJ`),
|
|
198
|
+
width: 40,
|
|
199
|
+
getValue: ({ patchedMember: object }: PlatformMember) => ({
|
|
200
|
+
// One contact per line so the cell stays readable, and wrap the text so all lines are visible
|
|
201
|
+
value: object.details.emergencyContacts.map(c => c.toString()).join('\n'),
|
|
202
|
+
style: {
|
|
203
|
+
alignment: {
|
|
204
|
+
wrapText: true,
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
}),
|
|
208
|
+
},
|
|
209
|
+
...XlsxTransformerColumnHelper.createColumnsForEmergencyContacts(),
|
|
210
|
+
|
|
194
211
|
// unverified data
|
|
195
212
|
{
|
|
196
213
|
id: 'unverifiedPhones',
|
|
@@ -406,6 +406,15 @@ export class AuthenticatedStructures {
|
|
|
406
406
|
const members = await Member.getMembersWithRegistrationForUser(user);
|
|
407
407
|
const filtered: MemberWithUsersRegistrationsAndGroups[] = [];
|
|
408
408
|
for (const member of members) {
|
|
409
|
+
if (STAMHOOFD.userMode === 'organization') {
|
|
410
|
+
if (!Context.organization) {
|
|
411
|
+
// Don't list any members
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
if (member.organizationId !== Context.organization.id) {
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
409
418
|
if (await Context.auth.canAccessMember(member, PermissionLevel.Read)) {
|
|
410
419
|
filtered.push(member);
|
|
411
420
|
}
|