@stamhoofd/backend 2.96.3 → 2.97.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.
@@ -1,6 +1,6 @@
1
1
  import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
2
2
  import { Email, Platform } from '@stamhoofd/models';
3
- import { EmailPreview, EmailRecipientsStatus, EmailStatus, Email as EmailStruct, PermissionLevel } from '@stamhoofd/structures';
3
+ import { EmailPreview, EmailStatus, Email as EmailStruct, PermissionLevel } from '@stamhoofd/structures';
4
4
 
5
5
  import { AutoEncoderPatchType, Decoder, patchObject } from '@simonbackx/simple-encoding';
6
6
  import { SimpleError } from '@simonbackx/simple-errors';
@@ -40,8 +40,8 @@ export class PatchEmailEndpoint extends Endpoint<Params, Query, Body, ResponseBo
40
40
  if (!model || (model.organizationId !== (organization?.id ?? null))) {
41
41
  throw new SimpleError({
42
42
  code: 'not_found',
43
- human: 'Email not found',
44
- message: $t(`9ddb6616-f62d-4c91-82a9-e5cf398e4c4a`),
43
+ message: 'Email not found',
44
+ human: $t(`9ddb6616-f62d-4c91-82a9-e5cf398e4c4a`),
45
45
  statusCode: 404,
46
46
  });
47
47
  }
@@ -118,8 +118,8 @@ export class PatchEmailEndpoint extends Endpoint<Params, Query, Body, ResponseBo
118
118
  if (model.status !== EmailStatus.Draft) {
119
119
  throw new SimpleError({
120
120
  code: 'not_draft',
121
- human: 'Email is not a draft',
122
- message: $t(`Je kan de ontvangerslijst alleen aanpassen als de e-mail nog een concept is`),
121
+ message: 'Email is not a draft',
122
+ human: $t(`ace4d2e8-88d6-479f-bd8b-d576cc0ed1f2`),
123
123
  statusCode: 400,
124
124
  });
125
125
  }
@@ -128,6 +128,29 @@ export class PatchEmailEndpoint extends Endpoint<Params, Query, Body, ResponseBo
128
128
  rebuild = true;
129
129
  }
130
130
 
131
+ if (request.body.sendAsEmail !== undefined) {
132
+ if (model.status !== EmailStatus.Draft) {
133
+ throw new SimpleError({
134
+ code: 'not_draft',
135
+ message: 'Email is not a draft',
136
+ human: $t(`02b05c0d-908b-4200-8fb8-5fc01f539514`),
137
+ statusCode: 400,
138
+ });
139
+ }
140
+
141
+ model.sendAsEmail = request.body.sendAsEmail ?? true;
142
+ }
143
+
144
+ if (request.body.showInMemberPortal !== undefined) {
145
+ model.showInMemberPortal = request.body.showInMemberPortal ?? false;
146
+
147
+ if (model.showInMemberPortal) {
148
+ if (!model.recipientFilter.canShowInMemberPortal) {
149
+ model.showInMemberPortal = false;
150
+ }
151
+ }
152
+ }
153
+
131
154
  // Attachments
132
155
  if (request.body.attachments !== undefined) {
133
156
  model.attachments = patchObject(model.attachments, request.body.attachments);
@@ -159,34 +182,6 @@ export class PatchEmailEndpoint extends Endpoint<Params, Query, Body, ResponseBo
159
182
  });
160
183
  }
161
184
 
162
- model.throwIfNotReadyToSend();
163
-
164
- const replacement = '{{unsubscribeUrl}}';
165
-
166
- if (model.html) {
167
- // Check email contains an unsubscribe button
168
- if (!model.html.includes(replacement)) {
169
- throw new SimpleError({
170
- code: 'missing_unsubscribe_button',
171
- message: 'Missing unsubscribe button',
172
- human: $t(`dd55e04b-e5d9-4d9a-befc-443eef4175a8`),
173
- field: 'html',
174
- });
175
- }
176
- }
177
-
178
- if (model.text) {
179
- // Check email contains an unsubscribe button
180
- if (!model.text.includes(replacement)) {
181
- throw new SimpleError({
182
- code: 'missing_unsubscribe_button',
183
- message: 'Missing unsubscribe button',
184
- human: $t(`dd55e04b-e5d9-4d9a-befc-443eef4175a8`),
185
- field: 'text',
186
- });
187
- }
188
- }
189
-
190
185
  // Preview the sending status
191
186
  await model.queueForSending();
192
187
  }
@@ -186,6 +186,173 @@ describe('Endpoint.GetEmailRecipients', () => {
186
186
  });
187
187
  });
188
188
 
189
+ test('It can request all email recipients of a single email in combination with other filters', async () => {
190
+ const email = new Email();
191
+ email.subject = 'test subject';
192
+ email.status = EmailStatus.Draft;
193
+ email.text = 'test email';
194
+ email.html = `<p style="margin: 0; padding: 0; line-height: 1.4;">test email</p>`;
195
+ email.json = {};
196
+ email.organizationId = organization.id;
197
+ email.senderId = sender2.id;
198
+ await email.save();
199
+
200
+ const emailRecipient = new EmailRecipient();
201
+ emailRecipient.email = 'jan.janssens@geenemail.com';
202
+ emailRecipient.firstName = 'Jan';
203
+ emailRecipient.lastName = 'Janssens';
204
+ emailRecipient.emailId = email.id;
205
+ emailRecipient.organizationId = organization.id;
206
+ await emailRecipient.save();
207
+
208
+ const request = Request.get({
209
+ path: baseUrl,
210
+ host: organization.getApiHost(),
211
+ query: new LimitedFilteredRequest({
212
+ filter: {
213
+ emailId: email.id,
214
+ email: {
215
+ $contains: 'jan',
216
+ },
217
+ },
218
+ limit: 10,
219
+ }),
220
+ headers: {
221
+ authorization: 'Bearer ' + token2.accessToken,
222
+ },
223
+ });
224
+ const result = await testServer.test(endpoint, request);
225
+ expect(result.body.results).toHaveLength(1);
226
+ expect(result.body.results[0]).toMatchObject({
227
+ id: emailRecipient.id,
228
+ });
229
+ });
230
+
231
+ test('[Regression] It can request all email recipients of a single email in combination with $and filters', async () => {
232
+ const email = new Email();
233
+ email.subject = 'test subject';
234
+ email.status = EmailStatus.Draft;
235
+ email.text = 'test email';
236
+ email.html = `<p style="margin: 0; padding: 0; line-height: 1.4;">test email</p>`;
237
+ email.json = {};
238
+ email.organizationId = organization.id;
239
+ email.senderId = sender2.id;
240
+ await email.save();
241
+
242
+ const emailRecipient = new EmailRecipient();
243
+ emailRecipient.email = 'jan.janssens@geenemail.com';
244
+ emailRecipient.firstName = 'Jan';
245
+ emailRecipient.lastName = 'Janssens';
246
+ emailRecipient.emailId = email.id;
247
+ emailRecipient.organizationId = organization.id;
248
+ await emailRecipient.save();
249
+
250
+ const request = Request.get({
251
+ path: baseUrl,
252
+ host: organization.getApiHost(),
253
+ query: new LimitedFilteredRequest({
254
+ filter: {
255
+ $and: [
256
+ { emailId: email.id },
257
+ { email: { $contains: 'jan' } },
258
+ ],
259
+ },
260
+ limit: 10,
261
+ }),
262
+ headers: {
263
+ authorization: 'Bearer ' + token2.accessToken,
264
+ },
265
+ });
266
+ const result = await testServer.test(endpoint, request);
267
+ expect(result.body.results).toHaveLength(1);
268
+ expect(result.body.results[0]).toMatchObject({
269
+ id: emailRecipient.id,
270
+ });
271
+ });
272
+
273
+ test('[Regression] It can request all email recipients of a single email in combination with multiple $and filters', async () => {
274
+ const email = new Email();
275
+ email.subject = 'test subject';
276
+ email.status = EmailStatus.Draft;
277
+ email.text = 'test email';
278
+ email.html = `<p style="margin: 0; padding: 0; line-height: 1.4;">test email</p>`;
279
+ email.json = {};
280
+ email.organizationId = organization.id;
281
+ email.senderId = sender2.id;
282
+ await email.save();
283
+
284
+ const emailRecipient = new EmailRecipient();
285
+ emailRecipient.email = 'jan.janssens@geenemail.com';
286
+ emailRecipient.firstName = 'Jan';
287
+ emailRecipient.lastName = 'Janssens';
288
+ emailRecipient.emailId = email.id;
289
+ emailRecipient.organizationId = organization.id;
290
+ await emailRecipient.save();
291
+
292
+ const request = Request.get({
293
+ path: baseUrl,
294
+ host: organization.getApiHost(),
295
+ query: new LimitedFilteredRequest({
296
+ filter: {
297
+ $and: [
298
+ { emailId: email.id },
299
+ { email: { $contains: 'jan' } },
300
+ ],
301
+ email: { $contains: 'ssens' },
302
+ },
303
+ limit: 10,
304
+ }),
305
+ headers: {
306
+ authorization: 'Bearer ' + token2.accessToken,
307
+ },
308
+ });
309
+ const result = await testServer.test(endpoint, request);
310
+ expect(result.body.results).toHaveLength(1);
311
+ expect(result.body.results[0]).toMatchObject({
312
+ id: emailRecipient.id,
313
+ });
314
+ });
315
+
316
+ test('It cannot request all email recipients of a single email in combination with $or filters', async () => {
317
+ const email = new Email();
318
+ email.subject = 'test subject';
319
+ email.status = EmailStatus.Draft;
320
+ email.text = 'test email';
321
+ email.html = `<p style="margin: 0; padding: 0; line-height: 1.4;">test email</p>`;
322
+ email.json = {};
323
+ email.organizationId = organization.id;
324
+ email.senderId = sender2.id;
325
+ await email.save();
326
+
327
+ const emailRecipient = new EmailRecipient();
328
+ emailRecipient.email = 'jan.janssens@geenemail.com';
329
+ emailRecipient.firstName = 'Jan';
330
+ emailRecipient.lastName = 'Janssens';
331
+ emailRecipient.emailId = email.id;
332
+ emailRecipient.organizationId = organization.id;
333
+ await emailRecipient.save();
334
+
335
+ const request = Request.get({
336
+ path: baseUrl,
337
+ host: organization.getApiHost(),
338
+ query: new LimitedFilteredRequest({
339
+ filter: {
340
+ $or: [
341
+ { emailId: email.id },
342
+ { email: { $contains: 'jan' } },
343
+ ],
344
+ },
345
+ limit: 10,
346
+ }),
347
+ headers: {
348
+ authorization: 'Bearer ' + token2.accessToken,
349
+ },
350
+ });
351
+ await expect(testServer.test(endpoint, request))
352
+ .rejects
353
+ .toThrow(STExpect.errorWithCode('permission_denied'));
354
+ });
355
+
189
356
  test('It cannot request all email recipients of a single email if read permission for another sender', async () => {
190
357
  const email = new Email();
191
358
  email.subject = 'test subject';
@@ -50,7 +50,7 @@ export class GetEmailRecipientsEndpoint extends Endpoint<Params, Query, Body, Re
50
50
  if (!await validateEmailRecipientFilter({ filter: q.filter, permissionLevel: PermissionLevel.Read })) {
51
51
  throw Context.auth.error({
52
52
  message: 'You do not have sufficient permissions to view all email recipients',
53
- human: $t(`Je hebt niet voldoende toegangsrechten om alle email ontvangers te bekijken. Filter op één specifieke e-mail.`),
53
+ human: $t(`d499972f-270c-44f6-ad7e-9d5359aef609`),
54
54
  });
55
55
  }
56
56
  }
@@ -9,9 +9,26 @@ export async function validateEmailRecipientFilter({ filter, permissionLevel }:
9
9
  emailId: FilterWrapperMarker,
10
10
  };
11
11
 
12
- const unwrapped = unwrapFilter(filter, requiredFilter);
12
+ let unwrapped = unwrapFilter(filter, requiredFilter);
13
13
  if (!unwrapped.match) {
14
- return false;
14
+ if (typeof filter === 'object'
15
+ && filter !== null
16
+ && filter['$and']
17
+ && Array.isArray(filter['$and'])
18
+ && Object.keys(filter).length >= 1 // does not matter if more than 1, because root is always $and together
19
+ && filter['$and'].length > 0
20
+ ) {
21
+ for (const subFilter of filter['$and']) {
22
+ unwrapped = unwrapFilter(subFilter as StamhoofdFilter, requiredFilter);
23
+ if (unwrapped.match) {
24
+ // Found!
25
+ break;
26
+ }
27
+ }
28
+ }
29
+ if (!unwrapped.match) {
30
+ return false;
31
+ }
15
32
  }
16
33
 
17
34
  const emailIds = typeof unwrapped.markerValue === 'string'
@@ -25,7 +42,7 @@ export async function validateEmailRecipientFilter({ filter, permissionLevel }:
25
42
  code: 'invalid_field',
26
43
  field: 'filter',
27
44
  message: 'You must filter on an email id of the email recipients you are trying to access',
28
- human: $t(`Je hebt niet voldoende toegangsrechten om alle email ontvangers te bekijken.`),
45
+ human: $t(`9f352fd4-5e4d-4899-81ce-b69889ebfe9d`),
29
46
  });
30
47
  }
31
48
 
@@ -55,7 +72,7 @@ export async function validateEmailRecipientFilter({ filter, permissionLevel }:
55
72
  if (!await Context.auth.canAccessEmail(email, permissionLevel)) {
56
73
  throw Context.auth.error({
57
74
  message: 'You do not have access to this email',
58
- human: $t(`Je hebt geen toegangsrechten tot de ontvangers van deze email`),
75
+ human: $t(`590a37ed-0f3a-4c66-8e20-08ac00ae761d`),
59
76
  });
60
77
  }
61
78
  }
@@ -161,9 +161,14 @@ export class AdminPermissionChecker {
161
161
  }
162
162
  }
163
163
  else {
164
- // User is limited to a scope
165
- if (this.user.organizationId) {
166
- return false;
164
+ if (STAMHOOFD.userMode === 'organization') {
165
+ // User is limited to a scope: can't access platform resources
166
+ if (this.user.organizationId) {
167
+ return false;
168
+ }
169
+ }
170
+ else {
171
+ // User can access platform resources (e.g. API keys)
167
172
  }
168
173
  }
169
174
 
@@ -605,7 +605,8 @@ export class AuthenticatedStructures {
605
605
 
606
606
  const memberBlobs = membersBlob.members;
607
607
 
608
- const registrationWithMemberBlobs = await Promise.all(registrations.map(async (registration) => {
608
+ const registrationWithMemberBlobs: RegistrationWithMemberBlob[] = [];
609
+ for (const registration of registrations) {
609
610
  const memberBlob = memberBlobs.find(m => m.id === registration.memberId);
610
611
  if (!memberBlob) {
611
612
  throw new Error('Member not found');
@@ -633,11 +634,12 @@ export class AuthenticatedStructures {
633
634
  }
634
635
  }
635
636
 
636
- return RegistrationWithMemberBlob.create({
637
+ const struct = RegistrationWithMemberBlob.create({
637
638
  ...r,
638
639
  member: memberBlob,
639
640
  });
640
- }));
641
+ registrationWithMemberBlobs.push(struct);
642
+ }
641
643
 
642
644
  return RegistrationsBlob.create({
643
645
  registrations: registrationWithMemberBlobs,
@@ -309,11 +309,11 @@ export class ContextInstance {
309
309
 
310
310
  if (this.organization && !this.organization.active) {
311
311
  // For inactive organizations, you always need permissions to view them
312
- if (!await Context.auth.hasFullAccess(this.organization.id)) {
312
+ if (!Context.auth.hasSomePlatformAccess() || !await Context.auth.hasFullAccess(this.organization.id)) {
313
313
  throw new SimpleError({
314
314
  code: 'archived',
315
- message: 'Full access is required to view inactive organizations',
316
- human: $t('31bc55e4-1cf3-495a-8b35-686a4cc25f69'),
315
+ message: 'Platform access is required to view inactive organizations',
316
+ human: $t('3e8dba08-a505-41ec-96c1-b2b5c1c17852'),
317
317
  statusCode: 401,
318
318
  });
319
319
  }
@@ -28,7 +28,7 @@ function stringToError(message: string) {
28
28
  new SimpleError({
29
29
  code: 'email_skipped_unsubscribed',
30
30
  message: 'Recipient has unsubscribed',
31
- human: $t('De ontvanger heeft zich afgemeld voor e-mails'),
31
+ human: $t('ffbebae7-eac3-44fe-863b-25942c5be7d0'),
32
32
  }),
33
33
  );
34
34
  }
@@ -38,7 +38,7 @@ function stringToError(message: string) {
38
38
  new SimpleError({
39
39
  code: 'email_skipped_unsubscribed',
40
40
  message: 'Recipient has unsubscribed from marketing',
41
- human: $t('De ontvanger heeft zich afgemeld voor e-mails'),
41
+ human: $t('ffbebae7-eac3-44fe-863b-25942c5be7d0'),
42
42
  }),
43
43
  );
44
44
  }
@@ -48,7 +48,7 @@ function stringToError(message: string) {
48
48
  new SimpleError({
49
49
  code: 'all_filtered',
50
50
  message: 'All recipients are filtered due to hard bounce or spam',
51
- human: $t('Deze ontvanger komt voor op de gedeelde bounce of spamlijst. De ontvanger was eerder permanent onbereikbaar of heeft eerder een e-mail als spam gemarkeerd.'),
51
+ human: $t('f6ca0939-f191-4aba-9c53-cd370453c0bc'),
52
52
  }),
53
53
  );
54
54
  }
@@ -440,14 +440,14 @@ export const memberFilterCompilers: SQLFilterDefinitions = {
440
440
  throw new SimpleError({
441
441
  code: 'not_found',
442
442
  message: 'This email does not exist.',
443
- human: $t('Deze e-mail bestaat niet (meer)'),
443
+ human: $t('491156e4-a75d-4487-a97e-b208cd3a1d11'),
444
444
  statusCode: 404,
445
445
  });
446
446
  }
447
447
  if (!await Context.auth.canAccessEmail(email)) {
448
448
  throw Context.auth.error({
449
449
  message: 'No permissions to access this email.',
450
- human: $t('Je hebt niet voldoende toegangsrechten om te filteren op deze e-mail'),
450
+ human: $t('be46002c-d77e-42ca-b163-23bde0bc628c'),
451
451
  });
452
452
  }
453
453
  },