@stamhoofd/backend 2.92.0 → 2.93.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 (24) hide show
  1. package/package.json +10 -10
  2. package/src/audit-logs/EmailLogger.ts +2 -2
  3. package/src/crons/amazon-ses.ts +100 -4
  4. package/src/crons/balance-emails.ts +1 -1
  5. package/src/email-recipient-loaders/receivable-balances.ts +3 -1
  6. package/src/endpoints/global/email/CreateEmailEndpoint.ts +8 -2
  7. package/src/endpoints/global/email/GetAdminEmailsEndpoint.ts +0 -2
  8. package/src/endpoints/global/email/PatchEmailEndpoint.ts +15 -5
  9. package/src/endpoints/global/email-recipients/GetEmailRecipientsCountEndpoint.ts +47 -0
  10. package/src/endpoints/global/email-recipients/GetEmailRecipientsEndpoint.test.ts +225 -0
  11. package/src/endpoints/global/email-recipients/GetEmailRecipientsEndpoint.ts +164 -0
  12. package/src/endpoints/global/email-recipients/helpers/validateEmailRecipientFilter.ts +64 -0
  13. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +5 -1
  14. package/src/endpoints/global/registration-periods/GetRegistrationPeriodsEndpoint.ts +19 -1
  15. package/src/helpers/EmailResumer.ts +2 -2
  16. package/src/seeds/1755790070-fill-email-recipient-errors.ts +96 -0
  17. package/src/seeds/1756115432-remove-old-drafts.ts +16 -0
  18. package/src/seeds/1756115433-fill-email-recipient-organization-id.ts +30 -0
  19. package/src/sql-filters/email-recipients.ts +59 -0
  20. package/src/sql-filters/emails.ts +32 -2
  21. package/src/sql-filters/members.ts +42 -1
  22. package/src/sql-filters/registration-periods.ts +5 -0
  23. package/src/sql-sorters/email-recipients.ts +69 -0
  24. /package/src/seeds/{1755181288-remove-duplicate-members.ts → 1755876819-remove-duplicate-members.ts} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stamhoofd/backend",
3
- "version": "2.92.0",
3
+ "version": "2.93.0",
4
4
  "main": "./dist/index.js",
5
5
  "exports": {
6
6
  ".": {
@@ -45,14 +45,14 @@
45
45
  "@simonbackx/simple-encoding": "2.22.0",
46
46
  "@simonbackx/simple-endpoints": "1.20.1",
47
47
  "@simonbackx/simple-logging": "^1.0.1",
48
- "@stamhoofd/backend-i18n": "2.92.0",
49
- "@stamhoofd/backend-middleware": "2.92.0",
50
- "@stamhoofd/email": "2.92.0",
51
- "@stamhoofd/models": "2.92.0",
52
- "@stamhoofd/queues": "2.92.0",
53
- "@stamhoofd/sql": "2.92.0",
54
- "@stamhoofd/structures": "2.92.0",
55
- "@stamhoofd/utility": "2.92.0",
48
+ "@stamhoofd/backend-i18n": "2.93.0",
49
+ "@stamhoofd/backend-middleware": "2.93.0",
50
+ "@stamhoofd/email": "2.93.0",
51
+ "@stamhoofd/models": "2.93.0",
52
+ "@stamhoofd/queues": "2.93.0",
53
+ "@stamhoofd/sql": "2.93.0",
54
+ "@stamhoofd/structures": "2.93.0",
55
+ "@stamhoofd/utility": "2.93.0",
56
56
  "archiver": "^7.0.1",
57
57
  "axios": "^1.8.2",
58
58
  "cookie": "^0.7.0",
@@ -70,5 +70,5 @@
70
70
  "publishConfig": {
71
71
  "access": "public"
72
72
  },
73
- "gitHead": "43d1edfd8061dada1d418a02691fe5fb158aca6a"
73
+ "gitHead": "05b8228a7fc3871aa767609a95a2c1511132e548"
74
74
  }
@@ -58,8 +58,8 @@ export const EmailLogger = new ModelLogger(Email, {
58
58
  type: AuditLogReplacementType.Email,
59
59
  })],
60
60
  ['c', AuditLogReplacement.create({
61
- value: Formatter.integer(model.recipientCount ?? 0),
62
- count: model.recipientCount ?? 0,
61
+ value: Formatter.integer(model.emailRecipientsCount ?? 0),
62
+ count: model.emailRecipientsCount ?? 0,
63
63
  })],
64
64
  ]);
65
65
  if (options.data.recipient) {
@@ -6,9 +6,10 @@ import {
6
6
  } from '@aws-sdk/client-sqs';
7
7
  import { registerCron } from '@stamhoofd/crons';
8
8
  import { Email, EmailAddress } from '@stamhoofd/email';
9
- import { AuditLog, Organization } from '@stamhoofd/models';
9
+ import { AuditLog, EmailRecipient, Organization } from '@stamhoofd/models';
10
10
  import { AuditLogReplacement, AuditLogReplacementType, AuditLogSource, AuditLogType } from '@stamhoofd/structures';
11
11
  import { ForwardHandler } from '../helpers/ForwardHandler';
12
+ import { Email as EmailModel } from '@stamhoofd/models';
12
13
 
13
14
  registerCron('checkComplaints', checkComplaints);
14
15
  registerCron('checkReplies', checkReplies);
@@ -52,13 +53,88 @@ async function saveLog({ email, organization, type, subType, subject, response,
52
53
  await log.save();
53
54
  }
54
55
 
56
+ async function storeEmailStatus({ headers, type, message }: { headers: Record<string, string>; type: 'hard-bounce' | 'soft-bounce' | 'complaint'; message: string }) {
57
+ const emailId = headers['x-email-id'];
58
+ const recipientId = headers['x-email-recipient-id'];
59
+
60
+ if (emailId && recipientId) {
61
+ // check
62
+ const emailRecipient = await EmailRecipient.select()
63
+ .where('id', recipientId)
64
+ .where('emailId', emailId)
65
+ .first(false);
66
+
67
+ if (!emailRecipient) {
68
+ console.log('[AWS FORWARDING] Invalid email or recipient id in headers', headers);
69
+ return;
70
+ }
71
+
72
+ let isNew = true;
73
+
74
+ switch (type) {
75
+ case 'hard-bounce': {
76
+ if (emailRecipient.hardBounceError) {
77
+ isNew = false;
78
+ }
79
+ emailRecipient.hardBounceError = message;
80
+ break;
81
+ }
82
+ case 'soft-bounce': {
83
+ if (emailRecipient.softBounceError) {
84
+ isNew = false;
85
+ }
86
+ emailRecipient.softBounceError = message;
87
+ break;
88
+ }
89
+ case 'complaint': {
90
+ if (emailRecipient.spamComplaintError) {
91
+ isNew = false;
92
+ }
93
+ emailRecipient.spamComplaintError = message;
94
+ break;
95
+ }
96
+ }
97
+
98
+ console.log('[AWS FORWARDING] Marking email recipient ' + recipientId + ' for email ' + emailId + ' as ' + type);
99
+ if (await emailRecipient.save()) {
100
+ if (isNew) {
101
+ await EmailModel.bumpNotificationCount(emailId, type);
102
+ }
103
+ }
104
+ }
105
+ }
106
+
107
+ function readHeaders(message: any) {
108
+ try {
109
+ const mail = message.mail;
110
+ const headers: Record<string, string> = {};
111
+ if (mail && typeof mail === 'object' && mail !== null && Array.isArray(mail.headers)) {
112
+ for (const header of mail.headers) {
113
+ if (header.name && header.value) {
114
+ headers[(header.name as string).toLowerCase()] = header.value;
115
+ }
116
+ }
117
+ }
118
+ else {
119
+ console.log('[AWS] Missing mail headers', message);
120
+ }
121
+ return headers;
122
+ }
123
+ catch (e) {
124
+ console.log('[AWS] Failed to read headers', e, message);
125
+ return {};
126
+ }
127
+ }
128
+
55
129
  async function handleBounce(message: any) {
56
130
  if (message.bounce) {
131
+ console.log('[AWS BOUNCES] Handling bounce message', message);
132
+ const headers = readHeaders(message);
133
+
57
134
  const b = message.bounce;
58
135
  // Block all receivers that generate a permanent bounce
59
136
  const type = b.bounceType;
60
137
  const subtype = b.bounceSubType;
61
-
62
138
  const source = message.mail.source;
63
139
 
64
140
  // try to find organization that is responsible for this e-mail address
@@ -80,6 +156,12 @@ async function handleBounce(message: any) {
80
156
  emailAddress.hardBounce = true;
81
157
  await emailAddress.save();
82
158
 
159
+ await storeEmailStatus({
160
+ headers,
161
+ type: 'hard-bounce',
162
+ message: recipient.diagnosticCode || 'Permanent bounce',
163
+ });
164
+
83
165
  await saveLog({
84
166
  id: b.feedbackId,
85
167
  email,
@@ -95,6 +177,13 @@ async function handleBounce(message: any) {
95
177
  type === 'Transient'
96
178
  ) {
97
179
  const organization: Organization | undefined = source ? await Organization.getByEmail(source) : undefined;
180
+
181
+ await storeEmailStatus({
182
+ headers,
183
+ type: 'soft-bounce',
184
+ message: recipient.diagnosticCode || 'Soft bounce',
185
+ });
186
+
98
187
  await saveLog({
99
188
  id: b.feedbackId,
100
189
  email,
@@ -116,6 +205,7 @@ async function handleBounce(message: any) {
116
205
 
117
206
  async function handleComplaint(message: any) {
118
207
  if (message.complaint) {
208
+ const headers = readHeaders(message);
119
209
  const b = message.complaint;
120
210
  const source = message.mail.source;
121
211
  const organization: Organization | undefined = source ? await Organization.getByEmail(source) : undefined;
@@ -129,10 +219,16 @@ async function handleComplaint(message: any) {
129
219
  await emailAddress.save();
130
220
 
131
221
  if (type !== 'not-spam') {
222
+ await storeEmailStatus({
223
+ headers,
224
+ type: 'complaint',
225
+ message: recipient.diagnosticCode || type || 'Complaint',
226
+ });
227
+
132
228
  if (type === 'virus' || type === 'fraud') {
133
229
  await saveLog({
134
230
  id: b.feedbackId,
135
- email: source,
231
+ email,
136
232
  organization,
137
233
  type: AuditLogType.EmailAddressFraudComplaint,
138
234
  subType: type || 'unknown',
@@ -144,7 +240,7 @@ async function handleComplaint(message: any) {
144
240
  else {
145
241
  await saveLog({
146
242
  id: b.feedbackId,
147
- email: source,
243
+ email,
148
244
  organization,
149
245
  type: AuditLogType.EmailAddressMarkedAsSpam,
150
246
  subType: type || 'unknown',
@@ -198,7 +198,7 @@ async function sendTemplate({
198
198
 
199
199
  try {
200
200
  const upToDate = await ContextInstance.startForUser(systemUser, organization, async () => {
201
- return await model.send();
201
+ return await model.queueForSending(true);
202
202
  });
203
203
 
204
204
  if (!upToDate) {
@@ -1,5 +1,5 @@
1
1
  import { CachedBalance, Email } from '@stamhoofd/models';
2
- import { BalanceItem as BalanceItemStruct, compileToInMemoryFilter, EmailRecipient, EmailRecipientFilterType, LimitedFilteredRequest, PaginatedResponse, receivableBalanceObjectContactInMemoryFilterCompilers, Replacement, StamhoofdFilter } from '@stamhoofd/structures';
2
+ import { BalanceItem as BalanceItemStruct, compileToInMemoryFilter, EmailRecipient, EmailRecipientFilterType, LimitedFilteredRequest, PaginatedResponse, receivableBalanceObjectContactInMemoryFilterCompilers, ReceivableBalanceType, Replacement, StamhoofdFilter } from '@stamhoofd/structures';
3
3
  import { Formatter } from '@stamhoofd/utility';
4
4
  import { GetReceivableBalancesEndpoint } from '../endpoints/organization/dashboard/receivable-balances/GetReceivableBalancesEndpoint';
5
5
 
@@ -22,6 +22,8 @@ async function fetch(query: LimitedFilteredRequest, subfilter: StamhoofdFilter |
22
22
  for (const email of contact.emails) {
23
23
  const recipient = EmailRecipient.create({
24
24
  objectId: balance.id, // Note: not set member, user or organization id here - should be the queryable balance id
25
+ userId: balance.objectType === ReceivableBalanceType.user ? balance.object.id : null,
26
+ memberId: balance.objectType === ReceivableBalanceType.member ? balance.object.id : null,
25
27
  firstName: contact.firstName,
26
28
  lastName: contact.lastName,
27
29
  email,
@@ -120,8 +120,14 @@ export class CreateEmailEndpoint extends Endpoint<Params, Query, Body, ResponseB
120
120
  await model.buildExampleRecipient();
121
121
  model.updateCount();
122
122
 
123
- if (request.body.status === EmailStatus.Sending || request.body.status === EmailStatus.Sent) {
124
- model.send().catch(console.error);
123
+ if (request.body.status === EmailStatus.Sending || request.body.status === EmailStatus.Sent || request.body.status === EmailStatus.Queued) {
124
+ if (!await Context.auth.canSendEmail(model)) {
125
+ throw Context.auth.error({
126
+ message: 'Cannot send emails from this sender',
127
+ human: $t('1b509614-30b0-484c-af72-57d4bc9ea788'),
128
+ });
129
+ }
130
+ await model.queueForSending();
125
131
  }
126
132
 
127
133
  return new Response(await model.getPreviewStructure());
@@ -138,8 +138,6 @@ export class GetAdminEmailsEndpoint extends Endpoint<Params, Query, Body, Respon
138
138
  query.limit(q.limit);
139
139
  }
140
140
 
141
- console.log('Building query for GetAdminEmailsEndpoint', query.getSQL());
142
-
143
141
  return query;
144
142
  }
145
143
 
@@ -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, EmailStatus, Email as EmailStruct, PermissionLevel } from '@stamhoofd/structures';
3
+ import { EmailPreview, EmailRecipientsStatus, 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';
@@ -119,6 +119,15 @@ export class PatchEmailEndpoint extends Endpoint<Params, Query, Body, ResponseBo
119
119
  }
120
120
 
121
121
  if (request.body.recipientFilter) {
122
+ if (model.status !== EmailStatus.Draft) {
123
+ throw new SimpleError({
124
+ code: 'not_draft',
125
+ human: 'Email is not a draft',
126
+ message: $t(`Je kan de ontvangerslijst alleen aanpassen als de e-mail nog een concept is`),
127
+ statusCode: 400,
128
+ });
129
+ }
130
+
122
131
  model.recipientFilter = patchObject(model.recipientFilter, request.body.recipientFilter);
123
132
  rebuild = true;
124
133
  }
@@ -133,13 +142,13 @@ export class PatchEmailEndpoint extends Endpoint<Params, Query, Body, ResponseBo
133
142
 
134
143
  if (rebuild) {
135
144
  await model.buildExampleRecipient();
136
- model.updateCount();
137
145
 
138
146
  // Force null - because we have stale data
139
- model.recipientCount = null;
147
+ model.emailRecipientsCount = null;
148
+ model.updateCount();
140
149
  }
141
150
 
142
- if (request.body.status === EmailStatus.Sending || request.body.status === EmailStatus.Sent) {
151
+ if (request.body.status === EmailStatus.Sending || request.body.status === EmailStatus.Sent || request.body.status === EmailStatus.Queued) {
143
152
  if (!await Context.auth.canSendEmail(model)) {
144
153
  throw Context.auth.error({
145
154
  message: 'Cannot send emails from this sender',
@@ -175,7 +184,8 @@ export class PatchEmailEndpoint extends Endpoint<Params, Query, Body, ResponseBo
175
184
  }
176
185
  }
177
186
 
178
- model.send().catch(console.error);
187
+ // Preview the sending status
188
+ await model.queueForSending();
179
189
  }
180
190
 
181
191
  return new Response(await model.getPreviewStructure());
@@ -0,0 +1,47 @@
1
+ import { Decoder } from '@simonbackx/simple-encoding';
2
+ import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
3
+ import { CountFilteredRequest, CountResponse } from '@stamhoofd/structures';
4
+
5
+ import { Context } from '../../../helpers/Context';
6
+ import { GetEmailRecipientsEndpoint } from './GetEmailRecipientsEndpoint';
7
+
8
+ type Params = Record<string, never>;
9
+ type Query = CountFilteredRequest;
10
+ type Body = undefined;
11
+ type ResponseBody = CountResponse;
12
+
13
+ export class GetEmailRecipientsCountEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
14
+ queryDecoder = CountFilteredRequest as Decoder<CountFilteredRequest>;
15
+
16
+ protected doesMatch(request: Request): [true, Params] | [false] {
17
+ if (request.method !== 'GET') {
18
+ return [false];
19
+ }
20
+
21
+ const params = Endpoint.parseParameters(request.url, '/email-recipients/count', {});
22
+
23
+ if (params) {
24
+ return [true, params as Params];
25
+ }
26
+ return [false];
27
+ }
28
+
29
+ async handle(request: DecodedRequest<Params, Query, Body>) {
30
+ const organization = await Context.setOptionalOrganizationScope();
31
+ await Context.authenticate();
32
+
33
+ if (!await Context.auth.canReadEmails(organization)) {
34
+ throw Context.auth.error();
35
+ }
36
+ const query = await GetEmailRecipientsEndpoint.buildQuery(request.query);
37
+
38
+ const count = await query
39
+ .count();
40
+
41
+ return new Response(
42
+ CountResponse.create({
43
+ count,
44
+ }),
45
+ );
46
+ }
47
+ }
@@ -0,0 +1,225 @@
1
+ import { STExpect, TestUtils } from '@stamhoofd/test-utils';
2
+ import { GetEmailRecipientsEndpoint } from './GetEmailRecipientsEndpoint';
3
+ import { AccessRight, EmailStatus, LimitedFilteredRequest, OrganizationEmail, PermissionLevel, Permissions, PermissionsResourceType, ResourcePermissions } from '@stamhoofd/structures';
4
+ import { Email, EmailRecipient, Organization, OrganizationFactory, RegistrationPeriod, RegistrationPeriodFactory, Token, User, UserFactory } from '@stamhoofd/models';
5
+ import { Request } from '@simonbackx/simple-endpoints';
6
+ import { testServer } from '../../../../tests/helpers/TestServer';
7
+
8
+ const baseUrl = `/email-recipients`;
9
+
10
+ describe('Endpoint.GetEmailRecipients', () => {
11
+ const endpoint = new GetEmailRecipientsEndpoint();
12
+ let period: RegistrationPeriod;
13
+ let organization: Organization;
14
+ let token: Token;
15
+ let user: User;
16
+ let sender: OrganizationEmail;
17
+ let sender2: OrganizationEmail;
18
+
19
+ let token2: Token;
20
+ let user2: User;
21
+
22
+ beforeAll(async () => {
23
+ TestUtils.setPermanentEnvironment('userMode', 'platform');
24
+ period = await new RegistrationPeriodFactory({
25
+ startDate: new Date(2023, 0, 1),
26
+ endDate: new Date(2023, 11, 31),
27
+ }).create();
28
+
29
+ organization = await new OrganizationFactory({ period })
30
+ .create();
31
+
32
+ sender = OrganizationEmail.create({
33
+ email: 'groepsleiding@voorbeeld.com',
34
+ name: 'Groepsleiding',
35
+ });
36
+ sender2 = OrganizationEmail.create({
37
+ email: 'kapoenen@voorbeeld.com',
38
+ name: 'Kapoenen',
39
+ });
40
+
41
+ organization.privateMeta.emails.push(sender);
42
+ organization.privateMeta.emails.push(sender2);
43
+ await organization.save();
44
+
45
+ user = await new UserFactory({
46
+ organization,
47
+ permissions: Permissions.create({
48
+ level: PermissionLevel.None,
49
+ resources: new Map([
50
+ [PermissionsResourceType.Senders, new Map([['', ResourcePermissions.create({
51
+ resourceName: sender.name!,
52
+ level: PermissionLevel.Read,
53
+ })]])],
54
+ ]),
55
+ }),
56
+ })
57
+ .create();
58
+
59
+ token = await Token.createToken(user);
60
+
61
+ user2 = await new UserFactory({
62
+ organization,
63
+ permissions: Permissions.create({
64
+ level: PermissionLevel.None,
65
+ resources: new Map([
66
+ [PermissionsResourceType.Senders, new Map([[sender2.id, ResourcePermissions.create({
67
+ resourceName: sender.name!,
68
+ level: PermissionLevel.Read,
69
+ })]])],
70
+ ]),
71
+ }),
72
+ })
73
+ .create();
74
+
75
+ token2 = await Token.createToken(user2);
76
+ });
77
+
78
+ test('It can request all email recipients if read permission for all senders', async () => {
79
+ const email = new Email();
80
+ email.subject = 'test subject';
81
+ email.status = EmailStatus.Draft;
82
+ email.text = 'test email';
83
+ email.html = `<p style="margin: 0; padding: 0; line-height: 1.4;">test email</p>`;
84
+ email.json = {};
85
+ email.organizationId = organization.id;
86
+ email.senderId = sender.id;
87
+ await email.save();
88
+
89
+ const emailRecipient = new EmailRecipient();
90
+ emailRecipient.email = 'jan.janssens@geenemail.com';
91
+ emailRecipient.firstName = 'Jan';
92
+ emailRecipient.lastName = 'Janssens';
93
+ emailRecipient.emailId = email.id;
94
+ emailRecipient.organizationId = organization.id;
95
+ await emailRecipient.save();
96
+
97
+ const request = Request.get({
98
+ path: baseUrl,
99
+ host: organization.getApiHost(),
100
+ query: new LimitedFilteredRequest({
101
+ filter: {},
102
+ limit: 10,
103
+ }),
104
+ headers: {
105
+ authorization: 'Bearer ' + token.accessToken,
106
+ },
107
+ });
108
+ const result = await testServer.test(endpoint, request);
109
+ expect(result.body.results).toHaveLength(1);
110
+ expect(result.body.results[0]).toMatchObject({
111
+ id: emailRecipient.id,
112
+ });
113
+ });
114
+
115
+ test('It can not request all email recipients if not read permission for all senders', async () => {
116
+ const email = new Email();
117
+ email.subject = 'test subject';
118
+ email.status = EmailStatus.Draft;
119
+ email.text = 'test email';
120
+ email.html = `<p style="margin: 0; padding: 0; line-height: 1.4;">test email</p>`;
121
+ email.json = {};
122
+ email.organizationId = organization.id;
123
+ email.senderId = sender2.id;
124
+ await email.save();
125
+
126
+ const emailRecipient = new EmailRecipient();
127
+ emailRecipient.email = 'jan.janssens@geenemail.com';
128
+ emailRecipient.firstName = 'Jan';
129
+ emailRecipient.lastName = 'Janssens';
130
+ emailRecipient.emailId = email.id;
131
+ emailRecipient.organizationId = organization.id;
132
+ await emailRecipient.save();
133
+
134
+ const request = Request.get({
135
+ path: baseUrl,
136
+ host: organization.getApiHost(),
137
+ query: new LimitedFilteredRequest({
138
+ filter: {},
139
+ limit: 10,
140
+ }),
141
+ headers: {
142
+ authorization: 'Bearer ' + token2.accessToken,
143
+ },
144
+ });
145
+ await expect(testServer.test(endpoint, request))
146
+ .rejects
147
+ .toThrow(STExpect.errorWithCode('permission_denied'));
148
+ });
149
+
150
+ test('It request all email recipients of a single email if read permission for that sender', async () => {
151
+ const email = new Email();
152
+ email.subject = 'test subject';
153
+ email.status = EmailStatus.Draft;
154
+ email.text = 'test email';
155
+ email.html = `<p style="margin: 0; padding: 0; line-height: 1.4;">test email</p>`;
156
+ email.json = {};
157
+ email.organizationId = organization.id;
158
+ email.senderId = sender2.id;
159
+ await email.save();
160
+
161
+ const emailRecipient = new EmailRecipient();
162
+ emailRecipient.email = 'jan.janssens@geenemail.com';
163
+ emailRecipient.firstName = 'Jan';
164
+ emailRecipient.lastName = 'Janssens';
165
+ emailRecipient.emailId = email.id;
166
+ emailRecipient.organizationId = organization.id;
167
+ await emailRecipient.save();
168
+
169
+ const request = Request.get({
170
+ path: baseUrl,
171
+ host: organization.getApiHost(),
172
+ query: new LimitedFilteredRequest({
173
+ filter: {
174
+ emailId: email.id,
175
+ },
176
+ limit: 10,
177
+ }),
178
+ headers: {
179
+ authorization: 'Bearer ' + token2.accessToken,
180
+ },
181
+ });
182
+ const result = await testServer.test(endpoint, request);
183
+ expect(result.body.results).toHaveLength(1);
184
+ expect(result.body.results[0]).toMatchObject({
185
+ id: emailRecipient.id,
186
+ });
187
+ });
188
+
189
+ test('It cannot request all email recipients of a single email if read permission for another sender', 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 = sender.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
+ },
215
+ limit: 10,
216
+ }),
217
+ headers: {
218
+ authorization: 'Bearer ' + token2.accessToken,
219
+ },
220
+ });
221
+ await expect(testServer.test(endpoint, request))
222
+ .rejects
223
+ .toThrow(STExpect.errorWithCode('permission_denied'));
224
+ });
225
+ });
@@ -0,0 +1,164 @@
1
+ import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
2
+ import { assertSort, CountFilteredRequest, EmailRecipient as EmailRecipientStruct, getSortFilter, LimitedFilteredRequest, PaginatedResponse, PermissionLevel, StamhoofdFilter } from '@stamhoofd/structures';
3
+
4
+ import { Decoder } from '@simonbackx/simple-encoding';
5
+ import { SimpleError } from '@simonbackx/simple-errors';
6
+ import { EmailRecipient } from '@stamhoofd/models';
7
+ import { applySQLSorter, compileToSQLFilter, SQLFilterDefinitions, SQLSortDefinitions } from '@stamhoofd/sql';
8
+ import { Context } from '../../../helpers/Context';
9
+ import { emailRecipientsFilterCompilers } from '../../../sql-filters/email-recipients';
10
+ import { emailRecipientSorters } from '../../../sql-sorters/email-recipients';
11
+ import { validateEmailRecipientFilter } from './helpers/validateEmailRecipientFilter';
12
+
13
+ type Params = Record<string, never>;
14
+ type Query = LimitedFilteredRequest;
15
+ type Body = undefined;
16
+ type ResponseBody = PaginatedResponse<EmailRecipientStruct[], LimitedFilteredRequest>;
17
+
18
+ const filterCompilers: SQLFilterDefinitions = emailRecipientsFilterCompilers;
19
+ const sorters: SQLSortDefinitions<EmailRecipient> = emailRecipientSorters;
20
+
21
+ export class GetEmailRecipientsEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
22
+ queryDecoder = LimitedFilteredRequest as Decoder<LimitedFilteredRequest>;
23
+
24
+ protected doesMatch(request: Request): [true, Params] | [false] {
25
+ if (request.method !== 'GET') {
26
+ return [false];
27
+ }
28
+
29
+ const params = Endpoint.parseParameters(request.url, '/email-recipients', {});
30
+
31
+ if (params) {
32
+ return [true, params as Params];
33
+ }
34
+ return [false];
35
+ }
36
+
37
+ static async buildQuery(q: CountFilteredRequest | LimitedFilteredRequest) {
38
+ const organization = Context.organization;
39
+ let scopeFilter: StamhoofdFilter | undefined = undefined;
40
+
41
+ if (organization) {
42
+ scopeFilter = {
43
+ organizationId: organization.id,
44
+ };
45
+ }
46
+ const canReadAllEmails = await Context.auth.canReadAllEmails(organization ?? null);
47
+
48
+ if (!canReadAllEmails) {
49
+ // Check if scope is correctly limited to a single email, otherwise throw an error.
50
+ if (!await validateEmailRecipientFilter({ filter: q.filter, permissionLevel: PermissionLevel.Read })) {
51
+ throw Context.auth.error({
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.`),
54
+ });
55
+ }
56
+ }
57
+
58
+ const query = EmailRecipient.select();
59
+
60
+ if (scopeFilter) {
61
+ query.where(await compileToSQLFilter(scopeFilter, filterCompilers));
62
+ }
63
+
64
+ if (q.filter) {
65
+ query.where(await compileToSQLFilter(q.filter, filterCompilers));
66
+ }
67
+
68
+ if (q.search) {
69
+ let searchFilter: StamhoofdFilter | null = null;
70
+
71
+ searchFilter = {
72
+ $or: [
73
+ {
74
+ email: {
75
+ $contains: q.search,
76
+ },
77
+ },
78
+ {
79
+ name: {
80
+ $contains: q.search,
81
+ },
82
+ },
83
+ ],
84
+ };
85
+
86
+ if (searchFilter) {
87
+ query.where(await compileToSQLFilter(searchFilter, filterCompilers));
88
+ }
89
+ }
90
+
91
+ if (q instanceof LimitedFilteredRequest) {
92
+ if (q.pageFilter) {
93
+ query.where(await compileToSQLFilter(q.pageFilter, filterCompilers));
94
+ }
95
+
96
+ q.sort = assertSort(q.sort, [{ key: 'id' }]);
97
+ applySQLSorter(query, q.sort, sorters);
98
+ query.limit(q.limit);
99
+ }
100
+
101
+ return query;
102
+ }
103
+
104
+ static async buildData(requestQuery: LimitedFilteredRequest) {
105
+ const query = await GetEmailRecipientsEndpoint.buildQuery(requestQuery);
106
+ const recipients = await query.fetch();
107
+
108
+ let next: LimitedFilteredRequest | undefined;
109
+
110
+ if (recipients.length >= requestQuery.limit) {
111
+ const lastObject = recipients[recipients.length - 1];
112
+ const nextFilter = getSortFilter(lastObject, sorters, requestQuery.sort);
113
+
114
+ next = new LimitedFilteredRequest({
115
+ filter: requestQuery.filter,
116
+ pageFilter: nextFilter,
117
+ sort: requestQuery.sort,
118
+ limit: requestQuery.limit,
119
+ search: requestQuery.search,
120
+ });
121
+
122
+ if (JSON.stringify(nextFilter) === JSON.stringify(requestQuery.pageFilter)) {
123
+ console.error('Found infinite loading loop for', requestQuery);
124
+ next = undefined;
125
+ }
126
+ }
127
+
128
+ return new PaginatedResponse<EmailRecipientStruct[], LimitedFilteredRequest>({
129
+ results: recipients.map(r => r.getStructure()),
130
+ next,
131
+ });
132
+ }
133
+
134
+ async handle(request: DecodedRequest<Params, Query, Body>) {
135
+ const organization = await Context.setOptionalOrganizationScope();
136
+ await Context.authenticate();
137
+
138
+ if (!await Context.auth.canReadEmails(organization)) {
139
+ throw Context.auth.error();
140
+ }
141
+
142
+ const maxLimit = Context.auth.hasSomePlatformAccess() ? 1000 : 100;
143
+
144
+ if (request.query.limit > maxLimit) {
145
+ throw new SimpleError({
146
+ code: 'invalid_field',
147
+ field: 'limit',
148
+ message: 'Limit can not be more than ' + maxLimit,
149
+ });
150
+ }
151
+
152
+ if (request.query.limit < 1) {
153
+ throw new SimpleError({
154
+ code: 'invalid_field',
155
+ field: 'limit',
156
+ message: 'Limit can not be less than 1',
157
+ });
158
+ }
159
+
160
+ return new Response(
161
+ await GetEmailRecipientsEndpoint.buildData(request.query),
162
+ );
163
+ }
164
+ }
@@ -0,0 +1,64 @@
1
+ import { SimpleError } from '@simonbackx/simple-errors';
2
+ import { Email } from '@stamhoofd/models';
3
+ import { FilterWrapperMarker, PermissionLevel, StamhoofdFilter, unwrapFilter, WrapperFilter } from '@stamhoofd/structures';
4
+ import { Context } from '../../../../helpers/Context';
5
+
6
+ export async function validateEmailRecipientFilter({ filter, permissionLevel }: { filter: StamhoofdFilter; permissionLevel: PermissionLevel }) {
7
+ // Require presence of a filter
8
+ const requiredFilter: WrapperFilter = {
9
+ emailId: FilterWrapperMarker,
10
+ };
11
+
12
+ const unwrapped = unwrapFilter(filter, requiredFilter);
13
+ if (!unwrapped.match) {
14
+ return false;
15
+ }
16
+
17
+ const emailIds = typeof unwrapped.markerValue === 'string'
18
+ ? [unwrapped.markerValue]
19
+ : unwrapFilter(unwrapped.markerValue as StamhoofdFilter, {
20
+ $in: FilterWrapperMarker,
21
+ })?.markerValue;
22
+
23
+ if (!Array.isArray(emailIds)) {
24
+ throw new SimpleError({
25
+ code: 'invalid_field',
26
+ field: 'filter',
27
+ 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.`),
29
+ });
30
+ }
31
+
32
+ if (emailIds.length === 0) {
33
+ throw new SimpleError({
34
+ code: 'invalid_field',
35
+ field: 'filter',
36
+ message: 'Filtering on an empty list of email ids is not supported',
37
+ });
38
+ }
39
+
40
+ for (const emailId of emailIds) {
41
+ if (typeof emailId !== 'string') {
42
+ throw new SimpleError({
43
+ code: 'invalid_field',
44
+ field: 'filter',
45
+ message: 'Invalid email ID in filter',
46
+ });
47
+ }
48
+ }
49
+
50
+ const emails = await Email.getByIDs(...emailIds as string[]);
51
+
52
+ console.log('Fetching recipients for emails', emails.map(g => g.subject));
53
+
54
+ for (const email of emails) {
55
+ if (!await Context.auth.canAccessEmail(email, permissionLevel)) {
56
+ throw Context.auth.error({
57
+ message: 'You do not have access to this email',
58
+ human: $t(`Je hebt geen toegangsrechten tot de ontvangers van deze email`),
59
+ });
60
+ }
61
+ }
62
+
63
+ return true;
64
+ }
@@ -966,7 +966,11 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
966
966
  }
967
967
 
968
968
  static shouldCheckIfMemberIsDuplicate(put: Member): boolean {
969
- if (put.details.firstName.length <= 3 && put.details.lastName.length <= 3) {
969
+ if (put.details.firstName === '???') {
970
+ return false;
971
+ }
972
+
973
+ if (put.details.name.length <= 3) {
970
974
  return false;
971
975
  }
972
976
 
@@ -5,6 +5,7 @@ import { Decoder } from '@simonbackx/simple-encoding';
5
5
  import { SimpleError } from '@simonbackx/simple-errors';
6
6
  import { RegistrationPeriod } from '@stamhoofd/models';
7
7
  import { applySQLSorter, compileToSQLFilter, SQLFilterDefinitions, SQLSortDefinitions } from '@stamhoofd/sql';
8
+ import { Context } from '../../../helpers/Context';
8
9
  import { registrationPeriodFilterCompilers } from '../../../sql-filters/registration-periods';
9
10
  import { registrationPeriodSorters } from '../../../sql-sorters/registration-periods';
10
11
 
@@ -33,9 +34,24 @@ export class GetRegistrationPeriodsEndpoint extends Endpoint<Params, Query, Body
33
34
  }
34
35
 
35
36
  static async buildQuery(q: CountFilteredRequest | LimitedFilteredRequest) {
36
- const scopeFilter: StamhoofdFilter | undefined = undefined;
37
+ let scopeFilter: StamhoofdFilter | undefined = undefined;
37
38
  const query = RegistrationPeriod.select();
38
39
 
40
+ if (STAMHOOFD.userMode === 'organization') {
41
+ const organization = Context.organization;
42
+
43
+ if (!organization) {
44
+ throw new SimpleError({
45
+ code: 'no_organization',
46
+ message: 'Organization is undefined on Context',
47
+ });
48
+ }
49
+
50
+ scopeFilter = {
51
+ organizationId: organization.id,
52
+ };
53
+ }
54
+
39
55
  if (scopeFilter) {
40
56
  query.where(await compileToSQLFilter(scopeFilter, filterCompilers));
41
57
  }
@@ -127,6 +143,8 @@ export class GetRegistrationPeriodsEndpoint extends Endpoint<Params, Query, Body
127
143
  });
128
144
  }
129
145
 
146
+ await Context.setUserOrganizationScope();
147
+
130
148
  return new Response(
131
149
  await GetRegistrationPeriodsEndpoint.buildData(request.query),
132
150
  );
@@ -6,7 +6,7 @@ import { ContextInstance } from './Context';
6
6
  export async function resumeEmails() {
7
7
  const query = SQL.select()
8
8
  .from(SQL.table(Email.table))
9
- .where(SQL.column('status'), EmailStatus.Sending);
9
+ .where(SQL.column('status'), [EmailStatus.Sending, EmailStatus.Queued]);
10
10
 
11
11
  const result = await query.fetch();
12
12
  const emails = Email.fromRows(result, Email.table);
@@ -28,7 +28,7 @@ export async function resumeEmails() {
28
28
 
29
29
  try {
30
30
  await ContextInstance.startForUser(user, organization, async () => {
31
- await email.send();
31
+ await email.resumeSending();
32
32
  });
33
33
  }
34
34
  catch (e) {
@@ -0,0 +1,96 @@
1
+ import { Migration } from '@simonbackx/simple-database';
2
+ import { SimpleError, SimpleErrors } from '@simonbackx/simple-errors';
3
+ import { EmailRecipient } from '@stamhoofd/models';
4
+
5
+ function stringToError(message: string) {
6
+ if (message === 'Recipient has hard bounced') {
7
+ return new SimpleErrors(
8
+ new SimpleError({
9
+ code: 'email_skipped_hard_bounce',
10
+ message: 'The recipient has hard bounced. This means that the email address is invalid or no longer exists.',
11
+ human: $t(`af49a569-ce88-48d9-ac37-81e594e16c03`),
12
+ }),
13
+ );
14
+ }
15
+
16
+ if (message === 'Recipient has marked as spam') {
17
+ return new SimpleErrors(
18
+ new SimpleError({
19
+ code: 'email_skipped_spam',
20
+ message: 'Recipient has marked as spam',
21
+ human: $t(`e6523f56-397e-4127-8bf7-8396f6f25a62`),
22
+ }),
23
+ );
24
+ }
25
+
26
+ if (message === 'Recipient has unsubscribed') {
27
+ return new SimpleErrors(
28
+ new SimpleError({
29
+ code: 'email_skipped_unsubscribed',
30
+ message: 'Recipient has unsubscribed',
31
+ human: $t('De ontvanger heeft zich afgemeld voor e-mails'),
32
+ }),
33
+ );
34
+ }
35
+
36
+ if (message === 'Recipient has unsubscribed from marketing') {
37
+ return new SimpleErrors(
38
+ new SimpleError({
39
+ code: 'email_skipped_unsubscribed',
40
+ message: 'Recipient has unsubscribed from marketing',
41
+ human: $t('De ontvanger heeft zich afgemeld voor e-mails'),
42
+ }),
43
+ );
44
+ }
45
+
46
+ if (message === 'All recipients are filtered due to hard bounce or spam') {
47
+ return new SimpleErrors(
48
+ new SimpleError({
49
+ code: 'all_filtered',
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.'),
52
+ }),
53
+ );
54
+ }
55
+
56
+ if (message === 'Invalid email address') {
57
+ return new SimpleErrors(
58
+ new SimpleError({
59
+ code: 'invalid_email_address',
60
+ message: 'Invalid email address',
61
+ human: $t(`cbbff442-758c-4f76-b8c2-26bb176fefcc`),
62
+ }),
63
+ );
64
+ }
65
+
66
+ return new SimpleErrors(
67
+ new SimpleError({
68
+ code: 'unknown_error',
69
+ message: message,
70
+ }),
71
+ );
72
+ }
73
+
74
+ export default new Migration(async () => {
75
+ if (STAMHOOFD.environment === 'test') {
76
+ console.log('skipped in tests');
77
+ return;
78
+ }
79
+
80
+ console.log('Start setting failError object on email recipients.');
81
+
82
+ const batchSize = 100;
83
+ let count = 0;
84
+
85
+ for await (const r of EmailRecipient.select()
86
+ .whereNot('failErrorMessage', null).limit(batchSize).all()) {
87
+ if (!r.failErrorMessage) {
88
+ continue;
89
+ }
90
+ r.failError = stringToError(r.failErrorMessage);
91
+ await r.save();
92
+ count++;
93
+ }
94
+
95
+ console.log('Finished saving ' + count + ' recipients with error object.');
96
+ });
@@ -0,0 +1,16 @@
1
+ import { Migration } from '@simonbackx/simple-database';
2
+ import { Email } from '@stamhoofd/models';
3
+ import { EmailStatus } from '@stamhoofd/structures';
4
+
5
+ export default new Migration(async () => {
6
+ if (STAMHOOFD.environment === 'test') {
7
+ console.log('skipped in tests');
8
+ return;
9
+ }
10
+
11
+ const { affectedRows } = await Email.delete()
12
+ .where('status', EmailStatus.Draft)
13
+ .where('createdAt', '<', new Date(Date.now() - 1000 * 60 * 60 * 24 * 30)) // older than 30 days
14
+ .delete();
15
+ console.log('Deleted ' + affectedRows + ' old draft emails.');
16
+ });
@@ -0,0 +1,30 @@
1
+ import { Migration } from '@simonbackx/simple-database';
2
+ import { Email, EmailRecipient } from '@stamhoofd/models';
3
+ import { SQL } from '@stamhoofd/sql';
4
+
5
+ export default new Migration(async () => {
6
+ if (STAMHOOFD.environment === 'test') {
7
+ console.log('skipped in tests');
8
+ return;
9
+ }
10
+
11
+ console.log('Start setting organizationId object on email recipients.');
12
+
13
+ const batchSize = 100;
14
+ let count = 0;
15
+
16
+ for await (const email of Email.select().where('organizationId', '!=', null).limit(batchSize).all()) {
17
+ if (!email.organizationId) {
18
+ continue;
19
+ }
20
+
21
+ await SQL.update(EmailRecipient.table)
22
+ .set('organizationId', email.organizationId)
23
+ .where('emailId', email.id)
24
+ .update();
25
+
26
+ count++;
27
+ }
28
+
29
+ console.log('Finished saving email recipients of ' + count + ' emails with organization id.');
30
+ });
@@ -0,0 +1,59 @@
1
+ import { baseSQLFilterCompilers, createColumnFilter, SQL, SQLConcat, SQLFilterDefinitions, SQLScalar, SQLValueType } from '@stamhoofd/sql';
2
+
3
+ export const emailRecipientsFilterCompilers: SQLFilterDefinitions = {
4
+ ...baseSQLFilterCompilers,
5
+ id: createColumnFilter({
6
+ expression: SQL.column('id'),
7
+ type: SQLValueType.String,
8
+ nullable: false,
9
+ }),
10
+ email: createColumnFilter({
11
+ expression: SQL.column('email'),
12
+ type: SQLValueType.String,
13
+ nullable: true,
14
+ }),
15
+ name: createColumnFilter({
16
+ expression: new SQLConcat(
17
+ SQL.column('firstName'),
18
+ new SQLScalar(' '),
19
+ SQL.column('lastName'),
20
+ ),
21
+ type: SQLValueType.String,
22
+ nullable: true,
23
+ }),
24
+ organizationId: createColumnFilter({
25
+ expression: SQL.column('organizationId'),
26
+ type: SQLValueType.String,
27
+ nullable: true,
28
+ }),
29
+ emailId: createColumnFilter({
30
+ expression: SQL.column('emailId'),
31
+ type: SQLValueType.String,
32
+ nullable: false,
33
+ }),
34
+ sentAt: createColumnFilter({
35
+ expression: SQL.column('sentAt'),
36
+ type: SQLValueType.Datetime,
37
+ nullable: true,
38
+ }),
39
+ failError: createColumnFilter({
40
+ expression: SQL.column('failError'),
41
+ type: SQLValueType.JSONObject,
42
+ nullable: true,
43
+ }),
44
+ spamComplaintError: createColumnFilter({
45
+ expression: SQL.column('spamComplaintError'),
46
+ type: SQLValueType.String,
47
+ nullable: true,
48
+ }),
49
+ softBounceError: createColumnFilter({
50
+ expression: SQL.column('softBounceError'),
51
+ type: SQLValueType.String,
52
+ nullable: true,
53
+ }),
54
+ hardBounceError: createColumnFilter({
55
+ expression: SQL.column('hardBounceError'),
56
+ type: SQLValueType.String,
57
+ nullable: true,
58
+ }),
59
+ };
@@ -47,11 +47,36 @@ export const emailFilterCompilers: SQLFilterDefinitions = {
47
47
  type: SQLValueType.String,
48
48
  nullable: false,
49
49
  }),
50
- recipientCount: createColumnFilter({
51
- expression: SQL.column('recipientCount'),
50
+ emailRecipientsCount: createColumnFilter({
51
+ expression: SQL.column('emailRecipientsCount'),
52
52
  type: SQLValueType.Number,
53
53
  nullable: true,
54
54
  }),
55
+ failedCount: createColumnFilter({
56
+ expression: SQL.column('failedCount'),
57
+ type: SQLValueType.Number,
58
+ nullable: false,
59
+ }),
60
+ softFailedCount: createColumnFilter({
61
+ expression: SQL.column('softFailedCount'),
62
+ type: SQLValueType.Number,
63
+ nullable: false,
64
+ }),
65
+ hardBouncesCount: createColumnFilter({
66
+ expression: SQL.column('hardBouncesCount'),
67
+ type: SQLValueType.Number,
68
+ nullable: false,
69
+ }),
70
+ softBouncesCount: createColumnFilter({
71
+ expression: SQL.column('softBouncesCount'),
72
+ type: SQLValueType.Number,
73
+ nullable: false,
74
+ }),
75
+ spamComplaintsCount: createColumnFilter({
76
+ expression: SQL.column('spamComplaintsCount'),
77
+ type: SQLValueType.Number,
78
+ nullable: false,
79
+ }),
55
80
  createdAt: createColumnFilter({
56
81
  expression: SQL.column('createdAt'),
57
82
  type: SQLValueType.Datetime,
@@ -62,4 +87,9 @@ export const emailFilterCompilers: SQLFilterDefinitions = {
62
87
  type: SQLValueType.Datetime,
63
88
  nullable: true,
64
89
  }),
90
+ senderId: createColumnFilter({
91
+ expression: SQL.column('senderId'),
92
+ type: SQLValueType.String,
93
+ nullable: true,
94
+ }),
65
95
  };
@@ -1,5 +1,5 @@
1
1
  import { SimpleError } from '@simonbackx/simple-errors';
2
- import { Member } from '@stamhoofd/models';
2
+ import { Email, Member } from '@stamhoofd/models';
3
3
  import { baseSQLFilterCompilers, createColumnFilter, createExistsFilter, SQL, SQLAge, SQLCast, SQLConcat, SQLFilterDefinitions, SQLValueType, SQLScalar, createWildcardColumnFilter, SQLJsonExtract } from '@stamhoofd/sql';
4
4
  import { AccessRight } from '@stamhoofd/structures';
5
5
  import { Context } from '../helpers/Context';
@@ -413,6 +413,47 @@ export const memberFilterCompilers: SQLFilterDefinitions = {
413
413
  ),
414
414
  organizationFilterCompilers,
415
415
  ),
416
+ 'emails': createExistsFilter(
417
+ SQL.select()
418
+ .from(
419
+ SQL.table('email_recipients'),
420
+ ).where(
421
+ SQL.column('memberId'),
422
+ SQL.column('members', 'id'),
423
+ ),
424
+ {
425
+ ...baseSQLFilterCompilers,
426
+ id: createColumnFilter({
427
+ expression: SQL.column('emailId'),
428
+ type: SQLValueType.String,
429
+ nullable: false,
430
+ checkPermission: async (filter) => {
431
+ if (typeof filter !== 'string') {
432
+ throw new SimpleError({
433
+ code: 'invalid_filter',
434
+ message: 'This filter structure is not supported here.',
435
+ });
436
+ }
437
+ const id = filter;
438
+ const email = await Email.getByID(id);
439
+ if (!email) {
440
+ throw new SimpleError({
441
+ code: 'not_found',
442
+ message: 'This email does not exist.',
443
+ human: $t('Deze e-mail bestaat niet (meer)'),
444
+ statusCode: 404,
445
+ });
446
+ }
447
+ if (!await Context.auth.canAccessEmail(email)) {
448
+ throw Context.auth.error({
449
+ message: 'No permissions to access this email.',
450
+ human: $t('Je hebt niet voldoende toegangsrechten om te filteren op deze e-mail'),
451
+ });
452
+ }
453
+ },
454
+ }),
455
+ },
456
+ ),
416
457
  'details': {
417
458
  ...baseSQLFilterCompilers,
418
459
  recordAnswers: createWildcardColumnFilter(
@@ -7,6 +7,11 @@ export const registrationPeriodFilterCompilers: SQLFilterDefinitions = {
7
7
  type: SQLValueType.String,
8
8
  nullable: false,
9
9
  }),
10
+ organizationId: createColumnFilter({
11
+ expression: SQL.column('organizationId'),
12
+ type: SQLValueType.String,
13
+ nullable: false,
14
+ }),
10
15
  startDate: createColumnFilter({
11
16
  expression: SQL.column('startDate'),
12
17
  type: SQLValueType.Datetime,
@@ -0,0 +1,69 @@
1
+ import { EmailRecipient } from '@stamhoofd/models';
2
+ import { SQL, SQLOrderBy, SQLOrderByDirection, SQLSortDefinitions } from '@stamhoofd/sql';
3
+ import { Formatter } from '@stamhoofd/utility';
4
+
5
+ export const emailRecipientSorters: SQLSortDefinitions<EmailRecipient> = {
6
+ // WARNING! TEST NEW SORTERS THOROUGHLY!
7
+ // Try to avoid creating sorters on fields that er not 1:1 with the database, that often causes pagination issues if not thought through
8
+ // An example: sorting on 'name' is not a good idea, because it is a concatenation of two fields.
9
+ // You might be tempted to use ORDER BY firstName, lastName, but that will not work as expected and it needs to be ORDER BY CONCAT(firstName, ' ', lastName)
10
+ // Why? Because ORDER BY firstName, lastName produces a different order dan ORDER BY CONCAT(firstName, ' ', lastName) if there are multiple people with spaces in the first name
11
+ // And that again causes issues with pagination because the next query will append a filter of name > 'John Doe' - causing duplicate and/or skipped results
12
+ // What if you need mapping? simply map the sorters in the frontend: name -> firstname, lastname, age -> birthDay, etc.
13
+
14
+ id: {
15
+ getValue(a) {
16
+ return a.id;
17
+ },
18
+ toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
19
+ return new SQLOrderBy({
20
+ column: SQL.column('id'),
21
+ direction,
22
+ });
23
+ },
24
+ },
25
+ sentAt: {
26
+ getValue(a) {
27
+ return a.sentAt ? Formatter.dateTimeIso(a.sentAt, 'UTC') : null;
28
+ },
29
+ toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
30
+ return new SQLOrderBy({
31
+ column: SQL.column('sentAt'),
32
+ direction,
33
+ });
34
+ },
35
+ },
36
+ email: {
37
+ getValue(a) {
38
+ return a.email;
39
+ },
40
+ toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
41
+ return new SQLOrderBy({
42
+ column: SQL.column('email'),
43
+ direction,
44
+ });
45
+ },
46
+ },
47
+ firstName: {
48
+ getValue(a) {
49
+ return a.firstName;
50
+ },
51
+ toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
52
+ return new SQLOrderBy({
53
+ column: SQL.column('firstName'),
54
+ direction,
55
+ });
56
+ },
57
+ },
58
+ lastName: {
59
+ getValue(a) {
60
+ return a.lastName;
61
+ },
62
+ toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
63
+ return new SQLOrderBy({
64
+ column: SQL.column('lastName'),
65
+ direction,
66
+ });
67
+ },
68
+ },
69
+ };