@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.
Files changed (100) hide show
  1. package/dist/src/factories/GroupFactory.d.ts.map +1 -1
  2. package/dist/src/factories/GroupFactory.js +5 -5
  3. package/dist/src/factories/GroupFactory.js.map +1 -1
  4. package/dist/src/helpers/EmailBuilder.d.ts +3 -2
  5. package/dist/src/helpers/EmailBuilder.d.ts.map +1 -1
  6. package/dist/src/helpers/EmailBuilder.js +29 -16
  7. package/dist/src/helpers/EmailBuilder.js.map +1 -1
  8. package/dist/src/helpers/GroupBuilder.d.ts.map +1 -1
  9. package/dist/src/helpers/GroupBuilder.js +0 -23
  10. package/dist/src/helpers/GroupBuilder.js.map +1 -1
  11. package/dist/src/migrations/1721050380-email-table.sql +24 -0
  12. package/dist/src/migrations/1721050381-email-recipients-table.sql +18 -0
  13. package/dist/src/migrations/1721342679-responsibility-groupId.sql +2 -0
  14. package/dist/src/migrations/1721342680-responsibility-groupId-foreign-key.sql +1 -0
  15. package/dist/src/migrations/1721400546-users-memberId.sql +3 -0
  16. package/dist/src/migrations/1721639159-membership-deleted-at.sql +2 -0
  17. package/dist/src/migrations/1721639160-membership-generated.sql +2 -0
  18. package/dist/src/migrations/1721841819-group-type.sql +2 -0
  19. package/dist/src/migrations/1722090482-events.sql +18 -0
  20. package/dist/src/models/DocumentTemplate.js +14 -14
  21. package/dist/src/models/DocumentTemplate.js.map +1 -1
  22. package/dist/src/models/Email.d.ts +41 -0
  23. package/dist/src/models/Email.d.ts.map +1 -0
  24. package/dist/src/models/Email.js +490 -0
  25. package/dist/src/models/Email.js.map +1 -0
  26. package/dist/src/models/EmailRecipient.d.ts +20 -0
  27. package/dist/src/models/EmailRecipient.d.ts.map +1 -0
  28. package/dist/src/models/EmailRecipient.js +95 -0
  29. package/dist/src/models/EmailRecipient.js.map +1 -0
  30. package/dist/src/models/EmailTemplate.d.ts +2 -1
  31. package/dist/src/models/EmailTemplate.d.ts.map +1 -1
  32. package/dist/src/models/EmailTemplate.js +4 -0
  33. package/dist/src/models/EmailTemplate.js.map +1 -1
  34. package/dist/src/models/Event.d.ts +19 -0
  35. package/dist/src/models/Event.d.ts.map +1 -0
  36. package/dist/src/models/Event.js +78 -0
  37. package/dist/src/models/Event.js.map +1 -0
  38. package/dist/src/models/Group.d.ts +2 -1
  39. package/dist/src/models/Group.d.ts.map +1 -1
  40. package/dist/src/models/Group.js +9 -22
  41. package/dist/src/models/Group.js.map +1 -1
  42. package/dist/src/models/Member.d.ts +1 -0
  43. package/dist/src/models/Member.d.ts.map +1 -1
  44. package/dist/src/models/Member.js +42 -11
  45. package/dist/src/models/Member.js.map +1 -1
  46. package/dist/src/models/MemberPlatformMembership.d.ts +7 -0
  47. package/dist/src/models/MemberPlatformMembership.d.ts.map +1 -1
  48. package/dist/src/models/MemberPlatformMembership.js +22 -0
  49. package/dist/src/models/MemberPlatformMembership.js.map +1 -1
  50. package/dist/src/models/MemberResponsibilityRecord.d.ts +3 -0
  51. package/dist/src/models/MemberResponsibilityRecord.d.ts.map +1 -1
  52. package/dist/src/models/MemberResponsibilityRecord.js +8 -0
  53. package/dist/src/models/MemberResponsibilityRecord.js.map +1 -1
  54. package/dist/src/models/Organization.d.ts +8 -1
  55. package/dist/src/models/Organization.d.ts.map +1 -1
  56. package/dist/src/models/Organization.js +37 -9
  57. package/dist/src/models/Organization.js.map +1 -1
  58. package/dist/src/models/OrganizationRegistrationPeriod.d.ts +1 -0
  59. package/dist/src/models/OrganizationRegistrationPeriod.d.ts.map +1 -1
  60. package/dist/src/models/OrganizationRegistrationPeriod.js +7 -0
  61. package/dist/src/models/OrganizationRegistrationPeriod.js.map +1 -1
  62. package/dist/src/models/RegistrationPeriod.d.ts +1 -0
  63. package/dist/src/models/RegistrationPeriod.d.ts.map +1 -1
  64. package/dist/src/models/RegistrationPeriod.js +12 -0
  65. package/dist/src/models/RegistrationPeriod.js.map +1 -1
  66. package/dist/src/models/User.d.ts +2 -1
  67. package/dist/src/models/User.d.ts.map +1 -1
  68. package/dist/src/models/User.js +13 -3
  69. package/dist/src/models/User.js.map +1 -1
  70. package/dist/src/models/index.d.ts +3 -0
  71. package/dist/src/models/index.d.ts.map +1 -1
  72. package/dist/src/models/index.js +3 -0
  73. package/dist/src/models/index.js.map +1 -1
  74. package/package.json +2 -2
  75. package/src/factories/GroupFactory.ts +6 -6
  76. package/src/helpers/EmailBuilder.ts +33 -18
  77. package/src/helpers/GroupBuilder.ts +0 -23
  78. package/src/migrations/1721050380-email-table.sql +24 -0
  79. package/src/migrations/1721050381-email-recipients-table.sql +18 -0
  80. package/src/migrations/1721342679-responsibility-groupId.sql +2 -0
  81. package/src/migrations/1721342680-responsibility-groupId-foreign-key.sql +1 -0
  82. package/src/migrations/1721400546-users-memberId.sql +3 -0
  83. package/src/migrations/1721639159-membership-deleted-at.sql +2 -0
  84. package/src/migrations/1721639160-membership-generated.sql +2 -0
  85. package/src/migrations/1721841819-group-type.sql +2 -0
  86. package/src/migrations/1722090482-events.sql +18 -0
  87. package/src/models/DocumentTemplate.ts +2 -2
  88. package/src/models/Email.ts +556 -0
  89. package/src/models/EmailRecipient.ts +81 -0
  90. package/src/models/EmailTemplate.ts +5 -1
  91. package/src/models/Event.ts +71 -0
  92. package/src/models/Group.ts +10 -37
  93. package/src/models/Member.ts +60 -12
  94. package/src/models/MemberPlatformMembership.ts +21 -0
  95. package/src/models/MemberResponsibilityRecord.ts +7 -0
  96. package/src/models/Organization.ts +42 -9
  97. package/src/models/OrganizationRegistrationPeriod.ts +8 -0
  98. package/src/models/RegistrationPeriod.ts +14 -0
  99. package/src/models/User.ts +13 -3
  100. 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
  }