@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.
- package/package.json +10 -10
- package/src/audit-logs/EmailLogger.ts +2 -2
- package/src/crons/amazon-ses.ts +100 -4
- package/src/crons/balance-emails.ts +1 -1
- package/src/email-recipient-loaders/receivable-balances.ts +3 -1
- package/src/endpoints/global/email/CreateEmailEndpoint.ts +8 -2
- package/src/endpoints/global/email/GetAdminEmailsEndpoint.ts +0 -2
- package/src/endpoints/global/email/PatchEmailEndpoint.ts +15 -5
- package/src/endpoints/global/email-recipients/GetEmailRecipientsCountEndpoint.ts +47 -0
- package/src/endpoints/global/email-recipients/GetEmailRecipientsEndpoint.test.ts +225 -0
- package/src/endpoints/global/email-recipients/GetEmailRecipientsEndpoint.ts +164 -0
- package/src/endpoints/global/email-recipients/helpers/validateEmailRecipientFilter.ts +64 -0
- package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +5 -1
- package/src/endpoints/global/registration-periods/GetRegistrationPeriodsEndpoint.ts +19 -1
- package/src/helpers/EmailResumer.ts +2 -2
- package/src/seeds/1755790070-fill-email-recipient-errors.ts +96 -0
- package/src/seeds/1756115432-remove-old-drafts.ts +16 -0
- package/src/seeds/1756115433-fill-email-recipient-organization-id.ts +30 -0
- package/src/sql-filters/email-recipients.ts +59 -0
- package/src/sql-filters/emails.ts +32 -2
- package/src/sql-filters/members.ts +42 -1
- package/src/sql-filters/registration-periods.ts +5 -0
- package/src/sql-sorters/email-recipients.ts +69 -0
- /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.
|
|
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.
|
|
49
|
-
"@stamhoofd/backend-middleware": "2.
|
|
50
|
-
"@stamhoofd/email": "2.
|
|
51
|
-
"@stamhoofd/models": "2.
|
|
52
|
-
"@stamhoofd/queues": "2.
|
|
53
|
-
"@stamhoofd/sql": "2.
|
|
54
|
-
"@stamhoofd/structures": "2.
|
|
55
|
-
"@stamhoofd/utility": "2.
|
|
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": "
|
|
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.
|
|
62
|
-
count: model.
|
|
61
|
+
value: Formatter.integer(model.emailRecipientsCount ?? 0),
|
|
62
|
+
count: model.emailRecipientsCount ?? 0,
|
|
63
63
|
})],
|
|
64
64
|
]);
|
|
65
65
|
if (options.data.recipient) {
|
package/src/crons/amazon-ses.ts
CHANGED
|
@@ -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
|
|
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
|
|
243
|
+
email,
|
|
148
244
|
organization,
|
|
149
245
|
type: AuditLogType.EmailAddressMarkedAsSpam,
|
|
150
246
|
subType: type || 'unknown',
|
|
@@ -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
|
-
|
|
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());
|
|
@@ -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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
51
|
-
expression: SQL.column('
|
|
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
|
+
};
|
/package/src/seeds/{1755181288-remove-duplicate-members.ts → 1755876819-remove-duplicate-members.ts}
RENAMED
|
File without changes
|