@stamhoofd/models 2.3.0 → 2.5.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 (133) 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/migrations/1722269236-group-waitinglist-id.sql +4 -0
  21. package/dist/src/migrations/1722525785-balance-item-paying-organization-id.sql +2 -0
  22. package/dist/src/migrations/1722525787-depending-balance-item.sql +2 -0
  23. package/dist/src/models/BalanceItem.d.ts +7 -0
  24. package/dist/src/models/BalanceItem.d.ts.map +1 -1
  25. package/dist/src/models/BalanceItem.js +34 -1
  26. package/dist/src/models/BalanceItem.js.map +1 -1
  27. package/dist/src/models/DocumentTemplate.js +14 -14
  28. package/dist/src/models/DocumentTemplate.js.map +1 -1
  29. package/dist/src/models/Email.d.ts +41 -0
  30. package/dist/src/models/Email.d.ts.map +1 -0
  31. package/dist/src/models/Email.js +490 -0
  32. package/dist/src/models/Email.js.map +1 -0
  33. package/dist/src/models/EmailRecipient.d.ts +20 -0
  34. package/dist/src/models/EmailRecipient.d.ts.map +1 -0
  35. package/dist/src/models/EmailRecipient.js +95 -0
  36. package/dist/src/models/EmailRecipient.js.map +1 -0
  37. package/dist/src/models/EmailTemplate.d.ts +2 -1
  38. package/dist/src/models/EmailTemplate.d.ts.map +1 -1
  39. package/dist/src/models/EmailTemplate.js +4 -0
  40. package/dist/src/models/EmailTemplate.js.map +1 -1
  41. package/dist/src/models/Event.d.ts +19 -0
  42. package/dist/src/models/Event.d.ts.map +1 -0
  43. package/dist/src/models/Event.js +78 -0
  44. package/dist/src/models/Event.js.map +1 -0
  45. package/dist/src/models/Group.d.ts +9 -1
  46. package/dist/src/models/Group.d.ts.map +1 -1
  47. package/dist/src/models/Group.js +25 -22
  48. package/dist/src/models/Group.js.map +1 -1
  49. package/dist/src/models/Member.d.ts +1 -0
  50. package/dist/src/models/Member.d.ts.map +1 -1
  51. package/dist/src/models/Member.js +42 -11
  52. package/dist/src/models/Member.js.map +1 -1
  53. package/dist/src/models/MemberPlatformMembership.d.ts +7 -0
  54. package/dist/src/models/MemberPlatformMembership.d.ts.map +1 -1
  55. package/dist/src/models/MemberPlatformMembership.js +22 -0
  56. package/dist/src/models/MemberPlatformMembership.js.map +1 -1
  57. package/dist/src/models/MemberResponsibilityRecord.d.ts +3 -0
  58. package/dist/src/models/MemberResponsibilityRecord.d.ts.map +1 -1
  59. package/dist/src/models/MemberResponsibilityRecord.js +8 -0
  60. package/dist/src/models/MemberResponsibilityRecord.js.map +1 -1
  61. package/dist/src/models/Order.js +1 -1
  62. package/dist/src/models/Order.js.map +1 -1
  63. package/dist/src/models/Organization.d.ts +3 -4
  64. package/dist/src/models/Organization.d.ts.map +1 -1
  65. package/dist/src/models/Organization.js +28 -24
  66. package/dist/src/models/Organization.js.map +1 -1
  67. package/dist/src/models/OrganizationRegistrationPeriod.d.ts +1 -0
  68. package/dist/src/models/OrganizationRegistrationPeriod.d.ts.map +1 -1
  69. package/dist/src/models/OrganizationRegistrationPeriod.js +7 -0
  70. package/dist/src/models/OrganizationRegistrationPeriod.js.map +1 -1
  71. package/dist/src/models/RegistrationPeriod.d.ts +1 -0
  72. package/dist/src/models/RegistrationPeriod.d.ts.map +1 -1
  73. package/dist/src/models/RegistrationPeriod.js +12 -0
  74. package/dist/src/models/RegistrationPeriod.js.map +1 -1
  75. package/dist/src/models/User.d.ts +2 -1
  76. package/dist/src/models/User.d.ts.map +1 -1
  77. package/dist/src/models/User.js +13 -3
  78. package/dist/src/models/User.js.map +1 -1
  79. package/dist/src/models/index.d.ts +3 -0
  80. package/dist/src/models/index.d.ts.map +1 -1
  81. package/dist/src/models/index.js +3 -0
  82. package/dist/src/models/index.js.map +1 -1
  83. package/package.json +2 -2
  84. package/src/factories/GroupFactory.ts +6 -6
  85. package/src/helpers/EmailBuilder.ts +33 -18
  86. package/src/helpers/GroupBuilder.ts +0 -23
  87. package/src/migrations/1721050380-email-table.sql +24 -0
  88. package/src/migrations/1721050381-email-recipients-table.sql +18 -0
  89. package/src/migrations/1721342679-responsibility-groupId.sql +2 -0
  90. package/src/migrations/1721342680-responsibility-groupId-foreign-key.sql +1 -0
  91. package/src/migrations/1721400546-users-memberId.sql +3 -0
  92. package/src/migrations/1721639159-membership-deleted-at.sql +2 -0
  93. package/src/migrations/1721639160-membership-generated.sql +2 -0
  94. package/src/migrations/1721841819-group-type.sql +2 -0
  95. package/src/migrations/1722090482-events.sql +18 -0
  96. package/src/migrations/1722269236-group-waitinglist-id.sql +4 -0
  97. package/src/migrations/1722525785-balance-item-paying-organization-id.sql +2 -0
  98. package/src/migrations/1722525787-depending-balance-item.sql +2 -0
  99. package/src/models/BalanceItem.ts +38 -1
  100. package/src/models/DocumentTemplate.ts +2 -2
  101. package/src/models/Email.ts +556 -0
  102. package/src/models/EmailRecipient.ts +81 -0
  103. package/src/models/EmailTemplate.ts +5 -1
  104. package/src/models/Event.ts +71 -0
  105. package/src/models/Group.ts +27 -37
  106. package/src/models/Member.ts +60 -12
  107. package/src/models/MemberPlatformMembership.ts +21 -0
  108. package/src/models/MemberResponsibilityRecord.ts +7 -0
  109. package/src/models/Order.ts +2 -2
  110. package/src/models/Organization.ts +32 -28
  111. package/src/models/OrganizationRegistrationPeriod.ts +8 -0
  112. package/src/models/RegistrationPeriod.ts +14 -0
  113. package/src/models/User.ts +13 -3
  114. package/src/models/index.ts +3 -0
  115. package/dist/src/assets/assets/Metropolis-Black.woff2 +0 -0
  116. package/dist/src/assets/assets/Metropolis-BlackItalic.woff2 +0 -0
  117. package/dist/src/assets/assets/Metropolis-Bold.woff2 +0 -0
  118. package/dist/src/assets/assets/Metropolis-BoldItalic.woff2 +0 -0
  119. package/dist/src/assets/assets/Metropolis-ExtraBold.woff2 +0 -0
  120. package/dist/src/assets/assets/Metropolis-ExtraBoldItalic.woff2 +0 -0
  121. package/dist/src/assets/assets/Metropolis-ExtraLight.woff2 +0 -0
  122. package/dist/src/assets/assets/Metropolis-ExtraLightItalic.woff2 +0 -0
  123. package/dist/src/assets/assets/Metropolis-Light.woff2 +0 -0
  124. package/dist/src/assets/assets/Metropolis-LightItalic.woff2 +0 -0
  125. package/dist/src/assets/assets/Metropolis-Medium.woff2 +0 -0
  126. package/dist/src/assets/assets/Metropolis-MediumItalic.woff2 +0 -0
  127. package/dist/src/assets/assets/Metropolis-Regular.woff2 +0 -0
  128. package/dist/src/assets/assets/Metropolis-RegularItalic.woff2 +0 -0
  129. package/dist/src/assets/assets/Metropolis-SemiBold.woff2 +0 -0
  130. package/dist/src/assets/assets/Metropolis-SemiBoldItalic.woff2 +0 -0
  131. package/dist/src/assets/assets/Metropolis-Thin.woff2 +0 -0
  132. package/dist/src/assets/assets/Metropolis-ThinItalic.woff2 +0 -0
  133. package/dist/src/assets/assets/logo.png +0 -0
@@ -18,15 +18,22 @@ export class BalanceItem extends Model {
18
18
  })
19
19
  id!: string;
20
20
 
21
+ // Receiving organization
22
+
21
23
  @column({ type: "string" })
22
24
  organizationId: string
23
25
 
26
+ // Payer: memberId, userId or payingOrganizationId
27
+
24
28
  @column({ type: "string", nullable: true })
25
29
  memberId: string | null = null;
26
30
 
27
31
  @column({ type: "string", nullable: true })
28
32
  userId: string | null = null;
29
33
 
34
+ @column({ type: "string", nullable: true })
35
+ payingOrganizationId: string | null = null;
36
+
30
37
  /**
31
38
  * The registration ID that is linked to this balance item
32
39
  */
@@ -39,6 +46,14 @@ export class BalanceItem extends Model {
39
46
  @column({ type: "string", nullable: true })
40
47
  orderId: string | null = null;
41
48
 
49
+ /**
50
+ * The depending balance item ID that is linked to this balance item
51
+ * -> as soon as this balance item is paid, we'll mark this balance item as pending if it is still hidden
52
+ * -> allows for a pay back system where one user needs to pay back a different user
53
+ */
54
+ @column({ type: "string", nullable: true })
55
+ dependingBalanceItemId: string | null = null;
56
+
42
57
  @column({ type: "string" })
43
58
  description = "";
44
59
 
@@ -137,6 +152,28 @@ export class BalanceItem extends Model {
137
152
  }
138
153
  }
139
154
  }
155
+
156
+ // Do we have a different connected balance item?
157
+ // Make it visible if this one is paid
158
+ if (this.dependingBalanceItemId) {
159
+ const depending = await BalanceItem.getByID(this.dependingBalanceItemId)
160
+ if (depending) {
161
+ if (this.status === BalanceItemStatus.Hidden) {
162
+ depending.status = BalanceItemStatus.Pending
163
+ await depending.save()
164
+
165
+ if (depending.memberId) {
166
+ const {Member} = await import("./Member");
167
+ await Member.updateOutstandingBalance([depending.memberId])
168
+ }
169
+
170
+ if (depending.registrationId) {
171
+ const {Registration} = await import("./Registration");
172
+ await Registration.updateOutstandingBalance([depending.registrationId], depending.organizationId)
173
+ }
174
+ }
175
+ }
176
+ }
140
177
  }
141
178
 
142
179
  async undoPaid(payment: Payment, organization: Organization) {
@@ -178,7 +215,7 @@ export class BalanceItem extends Model {
178
215
  }
179
216
 
180
217
  updateStatus() {
181
- this.status = this.pricePaid >= this.price ? BalanceItemStatus.Paid : BalanceItemStatus.Pending;
218
+ this.status = this.pricePaid >= this.price ? BalanceItemStatus.Paid : (this.pricePaid > 0 ? BalanceItemStatus.Pending : (this.status === BalanceItemStatus.Hidden ? BalanceItemStatus.Hidden : BalanceItemStatus.Pending));
182
219
  }
183
220
 
184
221
  static async deleteItems(items: BalanceItem[]) {
@@ -96,14 +96,14 @@ export class DocumentTemplate extends Model {
96
96
  id: "registration.startDate",
97
97
  type: RecordType.Date,
98
98
  }), // settings will be overwritten
99
- dateValue: group?.settings?.getStartDate({cycle: registration.cycle === group.cycle ? undefined : registration.cycle}) ?? null
99
+ dateValue: group?.settings?.startDate
100
100
  }),
101
101
  "registration.endDate": RecordDateAnswer.create({
102
102
  settings: RecordSettings.create({
103
103
  id: "registration.endDate",
104
104
  type: RecordType.Date,
105
105
  }), // settings will be overwritten
106
- dateValue: group?.settings?.getEndDate({cycle: registration.cycle === group.cycle ? undefined : registration.cycle}) ?? null
106
+ dateValue: group?.settings?.endDate
107
107
  }),
108
108
  "registration.price":
109
109
  RecordPriceAnswer.create({
@@ -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
+ }