@stamhoofd/models 2.3.0 → 2.4.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/dist/src/factories/GroupFactory.d.ts.map +1 -1
- package/dist/src/factories/GroupFactory.js +5 -5
- package/dist/src/factories/GroupFactory.js.map +1 -1
- package/dist/src/helpers/EmailBuilder.d.ts +3 -2
- package/dist/src/helpers/EmailBuilder.d.ts.map +1 -1
- package/dist/src/helpers/EmailBuilder.js +29 -16
- package/dist/src/helpers/EmailBuilder.js.map +1 -1
- package/dist/src/helpers/GroupBuilder.d.ts.map +1 -1
- package/dist/src/helpers/GroupBuilder.js +0 -23
- package/dist/src/helpers/GroupBuilder.js.map +1 -1
- package/dist/src/migrations/1721050380-email-table.sql +24 -0
- package/dist/src/migrations/1721050381-email-recipients-table.sql +18 -0
- package/dist/src/migrations/1721342679-responsibility-groupId.sql +2 -0
- package/dist/src/migrations/1721342680-responsibility-groupId-foreign-key.sql +1 -0
- package/dist/src/migrations/1721400546-users-memberId.sql +3 -0
- package/dist/src/migrations/1721639159-membership-deleted-at.sql +2 -0
- package/dist/src/migrations/1721639160-membership-generated.sql +2 -0
- package/dist/src/migrations/1721841819-group-type.sql +2 -0
- package/dist/src/migrations/1722090482-events.sql +18 -0
- package/dist/src/models/DocumentTemplate.js +14 -14
- package/dist/src/models/DocumentTemplate.js.map +1 -1
- package/dist/src/models/Email.d.ts +41 -0
- package/dist/src/models/Email.d.ts.map +1 -0
- package/dist/src/models/Email.js +490 -0
- package/dist/src/models/Email.js.map +1 -0
- package/dist/src/models/EmailRecipient.d.ts +20 -0
- package/dist/src/models/EmailRecipient.d.ts.map +1 -0
- package/dist/src/models/EmailRecipient.js +95 -0
- package/dist/src/models/EmailRecipient.js.map +1 -0
- package/dist/src/models/EmailTemplate.d.ts +2 -1
- package/dist/src/models/EmailTemplate.d.ts.map +1 -1
- package/dist/src/models/EmailTemplate.js +4 -0
- package/dist/src/models/EmailTemplate.js.map +1 -1
- package/dist/src/models/Event.d.ts +19 -0
- package/dist/src/models/Event.d.ts.map +1 -0
- package/dist/src/models/Event.js +78 -0
- package/dist/src/models/Event.js.map +1 -0
- package/dist/src/models/Group.d.ts +2 -1
- package/dist/src/models/Group.d.ts.map +1 -1
- package/dist/src/models/Group.js +9 -22
- package/dist/src/models/Group.js.map +1 -1
- package/dist/src/models/Member.d.ts +1 -0
- package/dist/src/models/Member.d.ts.map +1 -1
- package/dist/src/models/Member.js +42 -11
- package/dist/src/models/Member.js.map +1 -1
- package/dist/src/models/MemberPlatformMembership.d.ts +7 -0
- package/dist/src/models/MemberPlatformMembership.d.ts.map +1 -1
- package/dist/src/models/MemberPlatformMembership.js +22 -0
- package/dist/src/models/MemberPlatformMembership.js.map +1 -1
- package/dist/src/models/MemberResponsibilityRecord.d.ts +3 -0
- package/dist/src/models/MemberResponsibilityRecord.d.ts.map +1 -1
- package/dist/src/models/MemberResponsibilityRecord.js +8 -0
- package/dist/src/models/MemberResponsibilityRecord.js.map +1 -1
- package/dist/src/models/Organization.d.ts +8 -1
- package/dist/src/models/Organization.d.ts.map +1 -1
- package/dist/src/models/Organization.js +37 -9
- package/dist/src/models/Organization.js.map +1 -1
- package/dist/src/models/OrganizationRegistrationPeriod.d.ts +1 -0
- package/dist/src/models/OrganizationRegistrationPeriod.d.ts.map +1 -1
- package/dist/src/models/OrganizationRegistrationPeriod.js +7 -0
- package/dist/src/models/OrganizationRegistrationPeriod.js.map +1 -1
- package/dist/src/models/RegistrationPeriod.d.ts +1 -0
- package/dist/src/models/RegistrationPeriod.d.ts.map +1 -1
- package/dist/src/models/RegistrationPeriod.js +12 -0
- package/dist/src/models/RegistrationPeriod.js.map +1 -1
- package/dist/src/models/User.d.ts +2 -1
- package/dist/src/models/User.d.ts.map +1 -1
- package/dist/src/models/User.js +13 -3
- package/dist/src/models/User.js.map +1 -1
- package/dist/src/models/index.d.ts +3 -0
- package/dist/src/models/index.d.ts.map +1 -1
- package/dist/src/models/index.js +3 -0
- package/dist/src/models/index.js.map +1 -1
- package/package.json +2 -2
- package/src/factories/GroupFactory.ts +6 -6
- package/src/helpers/EmailBuilder.ts +33 -18
- package/src/helpers/GroupBuilder.ts +0 -23
- package/src/migrations/1721050380-email-table.sql +24 -0
- package/src/migrations/1721050381-email-recipients-table.sql +18 -0
- package/src/migrations/1721342679-responsibility-groupId.sql +2 -0
- package/src/migrations/1721342680-responsibility-groupId-foreign-key.sql +1 -0
- package/src/migrations/1721400546-users-memberId.sql +3 -0
- package/src/migrations/1721639159-membership-deleted-at.sql +2 -0
- package/src/migrations/1721639160-membership-generated.sql +2 -0
- package/src/migrations/1721841819-group-type.sql +2 -0
- package/src/migrations/1722090482-events.sql +18 -0
- package/src/models/DocumentTemplate.ts +2 -2
- package/src/models/Email.ts +556 -0
- package/src/models/EmailRecipient.ts +81 -0
- package/src/models/EmailTemplate.ts +5 -1
- package/src/models/Event.ts +71 -0
- package/src/models/Group.ts +10 -37
- package/src/models/Member.ts +60 -12
- package/src/models/MemberPlatformMembership.ts +21 -0
- package/src/models/MemberResponsibilityRecord.ts +7 -0
- package/src/models/Organization.ts +42 -9
- package/src/models/OrganizationRegistrationPeriod.ts +8 -0
- package/src/models/RegistrationPeriod.ts +14 -0
- package/src/models/User.ts +13 -3
- package/src/models/index.ts +3 -0
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
import { column, Model } from '@simonbackx/simple-database';
|
|
2
|
+
import { EditorSmartButton, EditorSmartVariable, EmailAttachment, EmailPreview, EmailRecipientFilter, EmailRecipientFilterType, EmailRecipientsStatus, EmailRecipient as EmailRecipientStruct, EmailStatus, Email as EmailStruct, LimitedFilteredRequest, PaginatedResponse, Recipient, SortItemDirection } from '@stamhoofd/structures';
|
|
3
|
+
import { v4 as uuidv4 } from "uuid";
|
|
4
|
+
|
|
5
|
+
import { AnyDecoder, ArrayDecoder } from '@simonbackx/simple-encoding';
|
|
6
|
+
import { SimpleError } from '@simonbackx/simple-errors';
|
|
7
|
+
import { QueueHandler } from '@stamhoofd/queues';
|
|
8
|
+
import { SQL, SQLWhereSign } from '@stamhoofd/sql';
|
|
9
|
+
import { EmailRecipient } from './EmailRecipient';
|
|
10
|
+
import { getEmailBuilder } from '../helpers/EmailBuilder';
|
|
11
|
+
import { Organization } from './Organization';
|
|
12
|
+
import { Formatter } from '@stamhoofd/utility';
|
|
13
|
+
import { Email as EmailClass } from "@stamhoofd/email";
|
|
14
|
+
|
|
15
|
+
export class Email extends Model {
|
|
16
|
+
static table = "emails";
|
|
17
|
+
|
|
18
|
+
@column({
|
|
19
|
+
primary: true, type: "string", beforeSave(value) {
|
|
20
|
+
return value ?? uuidv4();
|
|
21
|
+
}
|
|
22
|
+
})
|
|
23
|
+
id!: string;
|
|
24
|
+
|
|
25
|
+
@column({ type: "string", nullable: true })
|
|
26
|
+
organizationId: string|null = null;
|
|
27
|
+
|
|
28
|
+
@column({ type: "string", nullable: true})
|
|
29
|
+
userId: string|null = null;
|
|
30
|
+
|
|
31
|
+
@column({ type: "json", decoder: EmailRecipientFilter })
|
|
32
|
+
recipientFilter: EmailRecipientFilter = EmailRecipientFilter.create({})
|
|
33
|
+
|
|
34
|
+
@column({ type: "string", nullable: true })
|
|
35
|
+
subject: string|null
|
|
36
|
+
|
|
37
|
+
/** Raw json structure to edit the template */
|
|
38
|
+
@column({ type: "json", decoder: AnyDecoder })
|
|
39
|
+
json: any = {};
|
|
40
|
+
|
|
41
|
+
@column({ type: "string", nullable: true })
|
|
42
|
+
html: string|null = null
|
|
43
|
+
|
|
44
|
+
@column({ type: "string", nullable: true})
|
|
45
|
+
text: string|null = null
|
|
46
|
+
|
|
47
|
+
@column({ type: "string", nullable: true})
|
|
48
|
+
fromAddress: string|null = null
|
|
49
|
+
|
|
50
|
+
@column({ type: "string", nullable: true})
|
|
51
|
+
fromName: string|null = null
|
|
52
|
+
|
|
53
|
+
@column({ type: "integer", nullable: true})
|
|
54
|
+
recipientCount: number|null = null
|
|
55
|
+
|
|
56
|
+
@column({ type: "string" })
|
|
57
|
+
status = EmailStatus.Draft;
|
|
58
|
+
|
|
59
|
+
@column({ type: "string" })
|
|
60
|
+
recipientsStatus = EmailRecipientsStatus.NotCreated;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* todo: ignore automatically
|
|
64
|
+
*/
|
|
65
|
+
@column({ type: "json", decoder: new ArrayDecoder(EmailAttachment) })
|
|
66
|
+
attachments: EmailAttachment[] = []
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@column({
|
|
70
|
+
type: "datetime",
|
|
71
|
+
nullable: true
|
|
72
|
+
})
|
|
73
|
+
sentAt: Date|null = null
|
|
74
|
+
|
|
75
|
+
@column({
|
|
76
|
+
type: "datetime", beforeSave(old?: any) {
|
|
77
|
+
if (old !== undefined) {
|
|
78
|
+
return old;
|
|
79
|
+
}
|
|
80
|
+
const date = new Date()
|
|
81
|
+
date.setMilliseconds(0)
|
|
82
|
+
return date
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
createdAt: Date
|
|
86
|
+
|
|
87
|
+
@column({
|
|
88
|
+
type: "datetime", beforeSave() {
|
|
89
|
+
const date = new Date()
|
|
90
|
+
date.setMilliseconds(0)
|
|
91
|
+
return date
|
|
92
|
+
},
|
|
93
|
+
skipUpdate: true
|
|
94
|
+
})
|
|
95
|
+
updatedAt: Date
|
|
96
|
+
|
|
97
|
+
static recipientLoaders: Map<EmailRecipientFilterType, {
|
|
98
|
+
fetch(request: LimitedFilteredRequest): Promise<PaginatedResponse<EmailRecipientStruct[], LimitedFilteredRequest>>
|
|
99
|
+
count(request: LimitedFilteredRequest): Promise<number>
|
|
100
|
+
}> = new Map()
|
|
101
|
+
|
|
102
|
+
throwIfNotReadyToSend() {
|
|
103
|
+
if (this.subject == null || this.subject.length == 0) {
|
|
104
|
+
throw new SimpleError({
|
|
105
|
+
code: 'invalid_field',
|
|
106
|
+
message: 'Missing subject',
|
|
107
|
+
human: 'Vul een onderwerp in voor je een e-mail verstuurt'
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (this.text == null || this.text.length == 0) {
|
|
112
|
+
throw new SimpleError({
|
|
113
|
+
code: 'invalid_field',
|
|
114
|
+
message: 'Missing text',
|
|
115
|
+
human: 'Vul een tekst in voor je een e-mail verstuurt'
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (this.html == null || this.html.length == 0) {
|
|
120
|
+
throw new SimpleError({
|
|
121
|
+
code: 'invalid_field',
|
|
122
|
+
message: 'Missing html',
|
|
123
|
+
human: 'Vul een tekst in voor je een e-mail verstuurt'
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
getFromAddress() {
|
|
129
|
+
if (!this.fromName) {
|
|
130
|
+
return this.fromAddress!
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const cleanedName = Formatter.emailSenderName(this.fromName)
|
|
134
|
+
if (cleanedName.length < 2) {
|
|
135
|
+
return this.fromAddress!
|
|
136
|
+
}
|
|
137
|
+
return '"'+cleanedName+'" <'+this.fromAddress+'>'
|
|
138
|
+
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
getDefaultFromAddress(organization?: Organization|null): string {
|
|
142
|
+
let address = "noreply@stamhoofd.email";
|
|
143
|
+
|
|
144
|
+
if (organization) {
|
|
145
|
+
address = organization.uri+"@stamhoofd.email";
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (!this.fromName) {
|
|
149
|
+
return address
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const cleanedName = Formatter.emailSenderName(this.fromName)
|
|
153
|
+
if (cleanedName.length < 2) {
|
|
154
|
+
return address
|
|
155
|
+
}
|
|
156
|
+
return '"'+cleanedName+'" <'+address+'>'
|
|
157
|
+
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async send() {
|
|
161
|
+
this.throwIfNotReadyToSend()
|
|
162
|
+
await this.save();
|
|
163
|
+
|
|
164
|
+
const id = this.id;
|
|
165
|
+
return await QueueHandler.schedule('send-email', async function () {
|
|
166
|
+
let upToDate = await Email.getByID(id);
|
|
167
|
+
if (!upToDate) {
|
|
168
|
+
throw new SimpleError({
|
|
169
|
+
code: 'not_found',
|
|
170
|
+
message: 'Email not found',
|
|
171
|
+
human: 'De e-mail die je probeert te versturen bestaat niet meer'
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
if (upToDate.status === EmailStatus.Sent) {
|
|
175
|
+
// Already done
|
|
176
|
+
// In other cases -> queue has stopped and we can retry
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const organization = upToDate.organizationId ? await Organization.getByID(upToDate.organizationId) : null;
|
|
180
|
+
upToDate.throwIfNotReadyToSend()
|
|
181
|
+
|
|
182
|
+
let from = upToDate.getDefaultFromAddress(organization)
|
|
183
|
+
let replyTo: string | null = upToDate.getFromAddress();
|
|
184
|
+
|
|
185
|
+
if (!from) {
|
|
186
|
+
throw new SimpleError({
|
|
187
|
+
code: 'invalid_field',
|
|
188
|
+
message: 'Missing from',
|
|
189
|
+
human: 'Vul een afzender in voor je een e-mail verstuurt'
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Can we send from this e-mail or reply-to?
|
|
194
|
+
if (organization) {
|
|
195
|
+
if (organization.privateMeta.mailDomain && organization.privateMeta.mailDomainActive && upToDate.fromAddress!.endsWith("@"+organization.privateMeta.mailDomain)) {
|
|
196
|
+
from = upToDate.getFromAddress();
|
|
197
|
+
replyTo = null;
|
|
198
|
+
}
|
|
199
|
+
} else {
|
|
200
|
+
// Platform
|
|
201
|
+
const domains = Object.values(STAMHOOFD.domains.marketing)
|
|
202
|
+
|
|
203
|
+
for (const domain of domains) {
|
|
204
|
+
if (upToDate.fromAddress!.endsWith("@"+domain)) {
|
|
205
|
+
from = upToDate.getFromAddress();
|
|
206
|
+
replyTo = null;
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
upToDate.status = EmailStatus.Sending
|
|
213
|
+
upToDate.sentAt = upToDate.sentAt ?? new Date()
|
|
214
|
+
await upToDate.save();
|
|
215
|
+
|
|
216
|
+
// Create recipients if not yet created
|
|
217
|
+
await upToDate.buildRecipients()
|
|
218
|
+
|
|
219
|
+
// Refresh model
|
|
220
|
+
upToDate = await Email.getByID(id);
|
|
221
|
+
if (!upToDate) {
|
|
222
|
+
throw new SimpleError({
|
|
223
|
+
code: 'not_found',
|
|
224
|
+
message: 'Email not found',
|
|
225
|
+
human: 'De e-mail die je probeert te versturen bestaat niet meer'
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (upToDate.recipientsStatus !== EmailRecipientsStatus.Created) {
|
|
230
|
+
throw new SimpleError({
|
|
231
|
+
code: 'recipients_not_created',
|
|
232
|
+
message: 'Failed to create recipients',
|
|
233
|
+
human: 'Er ging iets mis bij het aanmaken van de afzenders.'
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Start actually sending in batches of recipients that are not yet sent
|
|
238
|
+
let idPointer = '';
|
|
239
|
+
const batchSize = 100;
|
|
240
|
+
const recipientsSet = new Set<string>();
|
|
241
|
+
|
|
242
|
+
// eslint-disable-next-line no-constant-condition
|
|
243
|
+
while (true) {
|
|
244
|
+
const q = SQL.select()
|
|
245
|
+
.from(SQL.table('email_recipients'))
|
|
246
|
+
.where(SQL.column('emailId'), upToDate.id)
|
|
247
|
+
.where(SQL.column('sentAt'), null)
|
|
248
|
+
.where(SQL.column('id'), SQLWhereSign.Greater, idPointer);
|
|
249
|
+
|
|
250
|
+
q.orderBy(SQL.column('id'), 'ASC')
|
|
251
|
+
q.limit(batchSize)
|
|
252
|
+
|
|
253
|
+
const data = await q.fetch();
|
|
254
|
+
|
|
255
|
+
const recipients = EmailRecipient.fromRows(data, 'email_recipients');
|
|
256
|
+
|
|
257
|
+
if (recipients.length == 0) {
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const sendingPromises: Promise<void>[] = [];
|
|
262
|
+
|
|
263
|
+
for (const recipient of recipients) {
|
|
264
|
+
if (recipientsSet.has(recipient.id)) {
|
|
265
|
+
console.error('Found duplicate recipient while sending email', recipient.id)
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
recipientsSet.add(recipient.email);
|
|
270
|
+
idPointer = recipient.id;
|
|
271
|
+
|
|
272
|
+
let promiseResolve: (value: void | PromiseLike<void>) => void
|
|
273
|
+
const promise = new Promise<void>((resolve) => {
|
|
274
|
+
promiseResolve = resolve;
|
|
275
|
+
});
|
|
276
|
+
sendingPromises.push(promise)
|
|
277
|
+
|
|
278
|
+
const callback = async (error: Error|null) => {
|
|
279
|
+
if (error === null) {
|
|
280
|
+
// Mark saved
|
|
281
|
+
recipient.sentAt = new Date();
|
|
282
|
+
await recipient.save()
|
|
283
|
+
} else {
|
|
284
|
+
recipient.failCount += 1;
|
|
285
|
+
recipient.failErrorMessage = error.message;
|
|
286
|
+
recipient.firstFailedAt = recipient.firstFailedAt ?? new Date();
|
|
287
|
+
recipient.lastFailedAt = new Date();
|
|
288
|
+
await recipient.save()
|
|
289
|
+
}
|
|
290
|
+
promiseResolve()
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Do send the email
|
|
294
|
+
// Create e-mail builder
|
|
295
|
+
const builder = await getEmailBuilder(organization ?? null, {
|
|
296
|
+
recipients: [
|
|
297
|
+
Recipient.create({
|
|
298
|
+
...recipient
|
|
299
|
+
})
|
|
300
|
+
],
|
|
301
|
+
from,
|
|
302
|
+
replyTo,
|
|
303
|
+
subject: upToDate.subject!,
|
|
304
|
+
html: upToDate.html,
|
|
305
|
+
type: "broadcast",
|
|
306
|
+
callback(error: Error|null ) {
|
|
307
|
+
callback(error).catch(console.error)
|
|
308
|
+
},
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
EmailClass.schedule(builder)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
await Promise.all(sendingPromises);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
console.log('Finished sending email', upToDate.id);
|
|
318
|
+
|
|
319
|
+
// Mark email as sent
|
|
320
|
+
upToDate.status = EmailStatus.Sent;
|
|
321
|
+
await upToDate.save();
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
updateCount() {
|
|
326
|
+
const id = this.id;
|
|
327
|
+
QueueHandler.schedule('email-count-'+this.id, async function () {
|
|
328
|
+
let upToDate = await Email.getByID(id);
|
|
329
|
+
|
|
330
|
+
if (!upToDate || upToDate.sentAt || !upToDate.id || upToDate.status !== EmailStatus.Draft) {
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (upToDate.recipientsStatus === EmailRecipientsStatus.Created) {
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
let count = 0;
|
|
339
|
+
|
|
340
|
+
try {
|
|
341
|
+
for (const subfilter of upToDate.recipientFilter.filters) {
|
|
342
|
+
|
|
343
|
+
// Create recipients
|
|
344
|
+
const loader = Email.recipientLoaders.get(subfilter.type);
|
|
345
|
+
|
|
346
|
+
if (!loader) {
|
|
347
|
+
throw new Error('Loader for type ' + subfilter.type+' has not been initialised on the Email model')
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const request = new LimitedFilteredRequest({
|
|
351
|
+
filter: subfilter.filter,
|
|
352
|
+
sort: [{key: 'id', order: SortItemDirection.ASC}],
|
|
353
|
+
limit: 1,
|
|
354
|
+
search: subfilter.search,
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
const c = await loader.count(request);
|
|
358
|
+
|
|
359
|
+
count += c
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Check if we have a more reliable recipientCount in the meantime
|
|
363
|
+
upToDate = await Email.getByID(id);
|
|
364
|
+
|
|
365
|
+
if (!upToDate) {
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
if (upToDate.recipientsStatus === EmailRecipientsStatus.Created) {
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
upToDate.recipientCount = count;
|
|
372
|
+
await upToDate.save();
|
|
373
|
+
} catch (e) {
|
|
374
|
+
console.error("Failed to update count for email", id);
|
|
375
|
+
console.error(e);
|
|
376
|
+
}
|
|
377
|
+
}).catch(console.error);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async buildRecipients() {
|
|
381
|
+
const id = this.id;
|
|
382
|
+
await QueueHandler.schedule('email-build-recipients-'+this.id, async function () {
|
|
383
|
+
const upToDate = await Email.getByID(id);
|
|
384
|
+
|
|
385
|
+
if (!upToDate || !upToDate.id) {
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (upToDate.recipientsStatus === EmailRecipientsStatus.Created) {
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (upToDate.status === EmailStatus.Sent) {
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// If it is already creating -> something went wrong (e.g. server restart) and we can safely try again
|
|
398
|
+
|
|
399
|
+
upToDate.recipientsStatus = EmailRecipientsStatus.Creating;
|
|
400
|
+
await upToDate.save();
|
|
401
|
+
|
|
402
|
+
let count = 0;
|
|
403
|
+
|
|
404
|
+
try {
|
|
405
|
+
// Delete all recipients
|
|
406
|
+
await SQL
|
|
407
|
+
.delete()
|
|
408
|
+
.from(
|
|
409
|
+
SQL.table('email_recipients')
|
|
410
|
+
)
|
|
411
|
+
.where(SQL.column('emailId'), upToDate.id);
|
|
412
|
+
|
|
413
|
+
for (const subfilter of upToDate.recipientFilter.filters) {
|
|
414
|
+
|
|
415
|
+
// Create recipients
|
|
416
|
+
const loader = Email.recipientLoaders.get(subfilter.type);
|
|
417
|
+
|
|
418
|
+
if (!loader) {
|
|
419
|
+
throw new Error('Loader for type ' + subfilter.type+' has not been initialised on the Email model')
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
let request: LimitedFilteredRequest|null = new LimitedFilteredRequest({
|
|
423
|
+
filter: subfilter.filter,
|
|
424
|
+
sort: [{key: 'id', order: SortItemDirection.ASC}],
|
|
425
|
+
limit: 1000,
|
|
426
|
+
search: subfilter.search,
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
while (request) {
|
|
430
|
+
console.log('Loading page', subfilter.type, request)
|
|
431
|
+
const response = await loader.fetch(request);
|
|
432
|
+
|
|
433
|
+
count += response.results.length;
|
|
434
|
+
|
|
435
|
+
for (const item of response.results) {
|
|
436
|
+
const recipient = new EmailRecipient();
|
|
437
|
+
recipient.emailId = upToDate.id;
|
|
438
|
+
recipient.email = item.email
|
|
439
|
+
recipient.firstName = item.firstName
|
|
440
|
+
recipient.lastName = item.lastName
|
|
441
|
+
recipient.replacements = item.replacements
|
|
442
|
+
|
|
443
|
+
await recipient.save();
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
request = response.next ?? null;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// todo: loop all members that match the filter in batches of 1000
|
|
451
|
+
// create a new row for every member + calculate the replacement values
|
|
452
|
+
// todo: do intermediate checks on whether the email was deleted, and stop processing if needed
|
|
453
|
+
|
|
454
|
+
upToDate.recipientsStatus = EmailRecipientsStatus.Created;
|
|
455
|
+
upToDate.recipientCount = count;
|
|
456
|
+
await upToDate.save();
|
|
457
|
+
} catch (e) {
|
|
458
|
+
console.error("Failed to build recipients for email", id);
|
|
459
|
+
console.error(e);
|
|
460
|
+
upToDate.recipientsStatus = EmailRecipientsStatus.NotCreated;
|
|
461
|
+
await upToDate.save();
|
|
462
|
+
}
|
|
463
|
+
}).catch(console.error);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
async buildExampleRecipient() {
|
|
467
|
+
const id = this.id;
|
|
468
|
+
await QueueHandler.schedule('email-build-recipients-'+this.id, async function () {
|
|
469
|
+
const upToDate = await Email.getByID(id);
|
|
470
|
+
|
|
471
|
+
if (!upToDate || upToDate.sentAt || !upToDate.id) {
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (upToDate.recipientsStatus !== EmailRecipientsStatus.NotCreated) {
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
try {
|
|
480
|
+
// Delete all recipients
|
|
481
|
+
await SQL
|
|
482
|
+
.delete()
|
|
483
|
+
.from(
|
|
484
|
+
SQL.table('email_recipients')
|
|
485
|
+
)
|
|
486
|
+
.where(SQL.column('emailId'), upToDate.id);
|
|
487
|
+
|
|
488
|
+
for (const subfilter of upToDate.recipientFilter.filters) {
|
|
489
|
+
|
|
490
|
+
// Create recipients
|
|
491
|
+
const loader = Email.recipientLoaders.get(subfilter.type);
|
|
492
|
+
|
|
493
|
+
if (!loader) {
|
|
494
|
+
throw new Error('Loader for type ' + subfilter.type+' has not been initialised on the Email model')
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
let request: LimitedFilteredRequest|null = new LimitedFilteredRequest({
|
|
498
|
+
filter: subfilter.filter,
|
|
499
|
+
sort: [{key: 'id', order: SortItemDirection.ASC}],
|
|
500
|
+
limit: 1,
|
|
501
|
+
search: subfilter.search,
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
while (request) {
|
|
505
|
+
console.log('Loading page', subfilter.type, request)
|
|
506
|
+
const response = await loader.fetch(request);
|
|
507
|
+
|
|
508
|
+
// Note: it is possible that a result in the database doesn't return a recipient (in memory filtering)
|
|
509
|
+
// so we do need pagination
|
|
510
|
+
|
|
511
|
+
for (const item of response.results) {
|
|
512
|
+
const recipient = new EmailRecipient();
|
|
513
|
+
recipient.emailId = upToDate.id;
|
|
514
|
+
recipient.email = item.email
|
|
515
|
+
recipient.firstName = item.firstName
|
|
516
|
+
recipient.lastName = item.lastName
|
|
517
|
+
recipient.replacements = item.replacements
|
|
518
|
+
await recipient.save();
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
request = response.next ?? null;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
console.warn('No example recipient found for email', id)
|
|
527
|
+
} catch (e) {
|
|
528
|
+
console.error("Failed to build example recipient for email", id);
|
|
529
|
+
console.error(e);
|
|
530
|
+
}
|
|
531
|
+
}).catch(console.error);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
getStructure() {
|
|
535
|
+
return EmailStruct.create(this)
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
async getPreviewStructure() {
|
|
539
|
+
const recipient = await SQL.select()
|
|
540
|
+
.from(SQL.table(EmailRecipient.table))
|
|
541
|
+
.where(SQL.column('emailId'), this.id)
|
|
542
|
+
.first(false);
|
|
543
|
+
|
|
544
|
+
const recipientRow = recipient ? EmailRecipient.fromRow(recipient[EmailRecipient.table]) : null;
|
|
545
|
+
|
|
546
|
+
const smartVariables = recipientRow ? EditorSmartVariable.forRecipient(recipientRow) : []
|
|
547
|
+
const smartButtons = recipientRow ? EditorSmartButton.forRecipient(recipientRow) : []
|
|
548
|
+
|
|
549
|
+
return EmailPreview.create({
|
|
550
|
+
...this,
|
|
551
|
+
exampleRecipient: recipientRow ? (recipientRow.getStructure()) : null,
|
|
552
|
+
smartVariables,
|
|
553
|
+
smartButtons
|
|
554
|
+
})
|
|
555
|
+
}
|
|
556
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { column, Model } from '@simonbackx/simple-database';
|
|
2
|
+
import { EmailRecipient as EmailRecipientStruct, Replacement } from '@stamhoofd/structures';
|
|
3
|
+
import { v4 as uuidv4 } from "uuid";
|
|
4
|
+
|
|
5
|
+
import { ArrayDecoder } from '@simonbackx/simple-encoding';
|
|
6
|
+
|
|
7
|
+
export class EmailRecipient extends Model {
|
|
8
|
+
static table = "email_recipients";
|
|
9
|
+
|
|
10
|
+
@column({
|
|
11
|
+
primary: true, type: "string", beforeSave(value) {
|
|
12
|
+
return value ?? uuidv4();
|
|
13
|
+
}
|
|
14
|
+
})
|
|
15
|
+
id!: string;
|
|
16
|
+
|
|
17
|
+
@column({ type: "string" })
|
|
18
|
+
emailId: string
|
|
19
|
+
|
|
20
|
+
@column({ type: "string", nullable: true })
|
|
21
|
+
firstName: string | null = null;
|
|
22
|
+
|
|
23
|
+
@column({ type: "string", nullable: true})
|
|
24
|
+
lastName: string | null = null;
|
|
25
|
+
|
|
26
|
+
@column({ type: "string" })
|
|
27
|
+
email: string;
|
|
28
|
+
|
|
29
|
+
@column({ type: "json", decoder: new ArrayDecoder(Replacement) })
|
|
30
|
+
replacements: Replacement[] = []
|
|
31
|
+
|
|
32
|
+
@column({ type: "string", nullable: true})
|
|
33
|
+
failErrorMessage: string|null = null
|
|
34
|
+
|
|
35
|
+
@column({ type: "integer" })
|
|
36
|
+
failCount = 0
|
|
37
|
+
|
|
38
|
+
@column({
|
|
39
|
+
type: "datetime",
|
|
40
|
+
nullable: true
|
|
41
|
+
})
|
|
42
|
+
firstFailedAt: Date|null = null
|
|
43
|
+
|
|
44
|
+
@column({
|
|
45
|
+
type: "datetime",
|
|
46
|
+
nullable: true
|
|
47
|
+
})
|
|
48
|
+
lastFailedAt: Date|null = null
|
|
49
|
+
|
|
50
|
+
@column({
|
|
51
|
+
type: "datetime",
|
|
52
|
+
nullable: true
|
|
53
|
+
})
|
|
54
|
+
sentAt: Date|null = null
|
|
55
|
+
|
|
56
|
+
@column({
|
|
57
|
+
type: "datetime", beforeSave(old?: any) {
|
|
58
|
+
if (old !== undefined) {
|
|
59
|
+
return old;
|
|
60
|
+
}
|
|
61
|
+
const date = new Date()
|
|
62
|
+
date.setMilliseconds(0)
|
|
63
|
+
return date
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
createdAt: Date
|
|
67
|
+
|
|
68
|
+
@column({
|
|
69
|
+
type: "datetime", beforeSave() {
|
|
70
|
+
const date = new Date()
|
|
71
|
+
date.setMilliseconds(0)
|
|
72
|
+
return date
|
|
73
|
+
},
|
|
74
|
+
skipUpdate: true
|
|
75
|
+
})
|
|
76
|
+
updatedAt: Date
|
|
77
|
+
|
|
78
|
+
getStructure() {
|
|
79
|
+
return EmailRecipientStruct.create(this)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { column, Model } from "@simonbackx/simple-database";
|
|
2
2
|
import { AnyDecoder } from "@simonbackx/simple-encoding";
|
|
3
|
-
import { EmailTemplateType } from "@stamhoofd/structures";
|
|
3
|
+
import { EmailRecipientFilterType, EmailTemplate as EmailTemplateStruct, EmailTemplateType } from "@stamhoofd/structures";
|
|
4
4
|
import { v4 as uuidv4 } from "uuid";
|
|
5
5
|
|
|
6
6
|
|
|
@@ -61,4 +61,8 @@ export class EmailTemplate extends Model {
|
|
|
61
61
|
skipUpdate: true
|
|
62
62
|
})
|
|
63
63
|
updatedAt: Date
|
|
64
|
+
|
|
65
|
+
getStructure() {
|
|
66
|
+
return EmailTemplateStruct.create(this)
|
|
67
|
+
}
|
|
64
68
|
}
|