@stamhoofd/models 2.94.0 → 2.95.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 (34) hide show
  1. package/dist/src/helpers/EmailBuilder.d.ts +27 -1
  2. package/dist/src/helpers/EmailBuilder.d.ts.map +1 -1
  3. package/dist/src/helpers/EmailBuilder.js +270 -25
  4. package/dist/src/helpers/EmailBuilder.js.map +1 -1
  5. package/dist/src/migrations/1756387016-registration-period-custom-name.sql +2 -0
  6. package/dist/src/migrations/1756391212-email-recipients-duplicate.sql +3 -0
  7. package/dist/src/models/Email.d.ts +3 -1
  8. package/dist/src/models/Email.d.ts.map +1 -1
  9. package/dist/src/models/Email.js +143 -4
  10. package/dist/src/models/Email.js.map +1 -1
  11. package/dist/src/models/EmailRecipient.d.ts +7 -1
  12. package/dist/src/models/EmailRecipient.d.ts.map +1 -1
  13. package/dist/src/models/EmailRecipient.js +28 -2
  14. package/dist/src/models/EmailRecipient.js.map +1 -1
  15. package/dist/src/models/Member.d.ts +1 -1
  16. package/dist/src/models/Member.d.ts.map +1 -1
  17. package/dist/src/models/Member.js +3 -3
  18. package/dist/src/models/Member.js.map +1 -1
  19. package/dist/src/models/RegistrationPeriod.d.ts +1 -0
  20. package/dist/src/models/RegistrationPeriod.d.ts.map +1 -1
  21. package/dist/src/models/RegistrationPeriod.js +4 -0
  22. package/dist/src/models/RegistrationPeriod.js.map +1 -1
  23. package/dist/src/models/User.d.ts.map +1 -1
  24. package/dist/src/models/User.js +5 -1
  25. package/dist/src/models/User.js.map +1 -1
  26. package/package.json +2 -2
  27. package/src/helpers/EmailBuilder.ts +282 -34
  28. package/src/migrations/1756387016-registration-period-custom-name.sql +2 -0
  29. package/src/migrations/1756391212-email-recipients-duplicate.sql +3 -0
  30. package/src/models/Email.ts +169 -7
  31. package/src/models/EmailRecipient.ts +32 -3
  32. package/src/models/Member.ts +3 -3
  33. package/src/models/RegistrationPeriod.ts +3 -0
  34. package/src/models/User.ts +7 -1
@@ -1,5 +1,5 @@
1
1
  import { column } from '@simonbackx/simple-database';
2
- import { EmailAttachment, EmailPreview, EmailRecipientFilter, EmailRecipientFilterType, EmailRecipientsStatus, EmailRecipient as EmailRecipientStruct, EmailStatus, Email as EmailStruct, EmailTemplateType, getExampleRecipient, LimitedFilteredRequest, PaginatedResponse, SortItemDirection, StamhoofdFilter, isSoftEmailRecipientError } from '@stamhoofd/structures';
2
+ import { User as UserStruct, EmailAttachment, EmailPreview, EmailRecipientFilter, EmailRecipientFilterType, EmailRecipientsStatus, EmailRecipient as EmailRecipientStruct, EmailStatus, Email as EmailStruct, EmailTemplateType, EmailWithRecipients, getExampleRecipient, isSoftEmailRecipientError, LimitedFilteredRequest, PaginatedResponse, Replacement, SortItemDirection, StamhoofdFilter, BaseOrganization } from '@stamhoofd/structures';
3
3
  import { v4 as uuidv4 } from 'uuid';
4
4
 
5
5
  import { AnyDecoder, ArrayDecoder } from '@simonbackx/simple-encoding';
@@ -8,10 +8,11 @@ import { I18n } from '@stamhoofd/backend-i18n';
8
8
  import { Email as EmailClass, EmailInterfaceRecipient } from '@stamhoofd/email';
9
9
  import { isAbortedError, QueueHandler, QueueHandlerOptions } from '@stamhoofd/queues';
10
10
  import { QueryableModel, readDynamicSQLExpression, SQL, SQLAlias, SQLCalculation, SQLCount, SQLPlusSign, SQLSelectAs, SQLWhereSign } from '@stamhoofd/sql';
11
- import { canSendFromEmail, fillRecipientReplacements, getEmailBuilder } from '../helpers/EmailBuilder';
11
+ import { canSendFromEmail, fillRecipientReplacements, getEmailBuilder, mergeReplacementsIfEqual, removeUnusedReplacements, stripRecipientReplacementsForWebDisplay, stripSensitiveRecipientReplacements } from '../helpers/EmailBuilder';
12
12
  import { EmailRecipient } from './EmailRecipient';
13
13
  import { EmailTemplate } from './EmailTemplate';
14
14
  import { Organization } from './Organization';
15
+ import { User } from './User';
15
16
 
16
17
  function errorToSimpleErrors(e: unknown) {
17
18
  if (isSimpleErrors(e)) {
@@ -758,6 +759,11 @@ export class Email extends QueryableModel {
758
759
  continue;
759
760
  }
760
761
 
762
+ if (recipient.duplicateOfRecipientId) {
763
+ skipped++;
764
+ continue;
765
+ }
766
+
761
767
  let promiseResolve: (value: void | PromiseLike<void>) => void;
762
768
  const promise = new Promise<void>((resolve) => {
763
769
  promiseResolve = resolve;
@@ -988,12 +994,15 @@ export class Email extends QueryableModel {
988
994
 
989
995
  abort.throwIfAborted();
990
996
 
997
+ console.log('Building recipients for email', id);
998
+
991
999
  // If it is already creating -> something went wrong (e.g. server restart) and we can safely try again
992
1000
 
993
1001
  upToDate.recipientsStatus = EmailRecipientsStatus.Creating;
994
1002
  await upToDate.save();
995
1003
 
996
1004
  const membersSet = new Set<string>();
1005
+ const emailsSet = new Set<string>();
997
1006
 
998
1007
  let count = 0;
999
1008
  let countWithoutEmail = 0;
@@ -1033,6 +1042,39 @@ export class Email extends QueryableModel {
1033
1042
  continue;
1034
1043
  }
1035
1044
 
1045
+ item.replacements = removeUnusedReplacements(upToDate.html ?? '', item.replacements);
1046
+
1047
+ let duplicateOfRecipientId: string | null = null;
1048
+ if (item.email && emailsSet.has(item.email)) {
1049
+ console.log('Found duplicate email recipient', item.email);
1050
+
1051
+ // Try to merge
1052
+ const existing = await EmailRecipient.select()
1053
+ .where('emailId', upToDate.id)
1054
+ .where('email', item.email)
1055
+ .fetch();
1056
+
1057
+ for (const other of existing) {
1058
+ const merged = mergeReplacementsIfEqual(other.replacements, item.replacements);
1059
+ if (merged !== false) {
1060
+ console.log('Found duplicate email recipient', item.email, other.id);
1061
+ duplicateOfRecipientId = other.id;
1062
+
1063
+ other.replacements = merged;
1064
+ other.firstName = other.firstName || item.firstName;
1065
+ other.lastName = other.lastName || item.lastName;
1066
+ await other.save();
1067
+
1068
+ item.replacements = merged;
1069
+
1070
+ break;
1071
+ }
1072
+ else {
1073
+ console.log('Could not merge duplicate email recipient', item.email, other.id, 'keeping both', other.replacements, item.replacements);
1074
+ }
1075
+ }
1076
+ }
1077
+
1036
1078
  const recipient = new EmailRecipient();
1037
1079
  recipient.emailType = upToDate.emailType;
1038
1080
  recipient.objectId = item.objectId;
@@ -1044,6 +1086,7 @@ export class Email extends QueryableModel {
1044
1086
  recipient.memberId = item.memberId ?? null;
1045
1087
  recipient.userId = item.userId ?? null;
1046
1088
  recipient.organizationId = upToDate.organizationId ?? null;
1089
+ recipient.duplicateOfRecipientId = duplicateOfRecipientId;
1047
1090
 
1048
1091
  await recipient.save();
1049
1092
 
@@ -1051,7 +1094,11 @@ export class Email extends QueryableModel {
1051
1094
  membersSet.add(recipient.memberId);
1052
1095
  }
1053
1096
 
1054
- if (!recipient.email) {
1097
+ if (recipient.email) {
1098
+ emailsSet.add(recipient.email);
1099
+ }
1100
+
1101
+ if (!recipient.email || duplicateOfRecipientId) {
1055
1102
  countWithoutEmail += 1;
1056
1103
  }
1057
1104
  else {
@@ -1125,13 +1172,21 @@ export class Email extends QueryableModel {
1125
1172
 
1126
1173
  for (const item of response.results) {
1127
1174
  const recipient = new EmailRecipient();
1175
+ recipient.emailType = upToDate.emailType;
1128
1176
  recipient.emailId = upToDate.id;
1177
+ recipient.objectId = item.objectId;
1129
1178
  recipient.email = item.email;
1130
1179
  recipient.firstName = item.firstName;
1131
1180
  recipient.lastName = item.lastName;
1132
1181
  recipient.replacements = item.replacements;
1182
+ recipient.memberId = item.memberId ?? null;
1183
+ recipient.userId = item.userId ?? null;
1184
+ recipient.organizationId = upToDate.organizationId ?? null;
1133
1185
  await recipient.save();
1134
- return;
1186
+
1187
+ if (recipient.email || recipient.userId) {
1188
+ return;
1189
+ }
1135
1190
  }
1136
1191
 
1137
1192
  request = null;
@@ -1154,12 +1209,13 @@ export class Email extends QueryableModel {
1154
1209
  async getPreviewStructure() {
1155
1210
  const emailRecipient = await EmailRecipient.select()
1156
1211
  .where('emailId', this.id)
1212
+ .where('email', '!=', null)
1157
1213
  .first(false);
1158
1214
 
1159
1215
  let recipientRow: EmailRecipientStruct | undefined;
1160
1216
 
1161
1217
  if (emailRecipient) {
1162
- recipientRow = emailRecipient.getStructure();
1218
+ recipientRow = await emailRecipient.getStructure();
1163
1219
  }
1164
1220
 
1165
1221
  if (!recipientRow) {
@@ -1167,18 +1223,124 @@ export class Email extends QueryableModel {
1167
1223
  }
1168
1224
 
1169
1225
  const virtualRecipient = recipientRow.getRecipient();
1226
+ const organization = this.organizationId ? (await Organization.getByID(this.organizationId))! : null;
1170
1227
 
1171
1228
  await fillRecipientReplacements(virtualRecipient, {
1172
- organization: this.organizationId ? (await Organization.getByID(this.organizationId))! : null,
1229
+ organization,
1173
1230
  from: this.getFromAddress(),
1174
1231
  replyTo: null,
1232
+ forPreview: true,
1233
+ forceRefresh: !this.sentAt,
1175
1234
  });
1176
-
1177
1235
  recipientRow.replacements = virtualRecipient.replacements;
1178
1236
 
1237
+ let user: UserStruct | null = null;
1238
+ if (this.userId) {
1239
+ const u = await User.getByID(this.userId);
1240
+ if (u) {
1241
+ user = u.getStructure();
1242
+ }
1243
+ }
1244
+
1245
+ let organizationStruct: BaseOrganization | null = null;
1246
+ if (organization) {
1247
+ organizationStruct = organization.getBaseStructure();
1248
+ }
1249
+
1179
1250
  return EmailPreview.create({
1180
1251
  ...this,
1252
+ user,
1253
+ organization: organizationStruct,
1181
1254
  exampleRecipient: recipientRow,
1182
1255
  });
1183
1256
  }
1257
+
1258
+ async getStructureForUser(user: User, memberIds: string[]) {
1259
+ const emailRecipients = await EmailRecipient.select()
1260
+ .where('emailId', this.id)
1261
+ .where('memberId', memberIds)
1262
+ .fetch();
1263
+ const organization = this.organizationId ? (await Organization.getByID(this.organizationId))! : null;
1264
+
1265
+ const recipientsMap: Map<string, EmailRecipient> = new Map();
1266
+ for (const memberId of memberIds) {
1267
+ const preferred = emailRecipients.find(e => e.memberId === memberId && (e.userId === user.id || e.email === user.email));
1268
+ if (preferred) {
1269
+ recipientsMap.set(preferred.duplicateOfRecipientId ?? preferred.id, preferred);
1270
+ continue;
1271
+ }
1272
+
1273
+ const byMember = emailRecipients.find(e => e.memberId === memberId && e.userId === null && e.email === null);
1274
+ if (byMember) {
1275
+ recipientsMap.set(byMember.duplicateOfRecipientId ?? byMember.id, byMember);
1276
+ continue;
1277
+ }
1278
+ const anyData = emailRecipients.find(e => e.memberId === memberId);
1279
+ if (anyData) {
1280
+ recipientsMap.set(anyData.duplicateOfRecipientId ?? anyData.id, anyData);
1281
+ continue;
1282
+ }
1283
+ }
1284
+
1285
+ // Remove duplicates that are marked as the same recipient
1286
+ const cleanedRecipients: EmailRecipient[] = [...recipientsMap.values()];
1287
+ const structures = await EmailRecipient.getStructures(cleanedRecipients);
1288
+
1289
+ const virtualRecipients = structures.map((struct) => {
1290
+ const recipient = struct.getRecipient();
1291
+
1292
+ return {
1293
+ struct,
1294
+ recipient,
1295
+ };
1296
+ });
1297
+ for (const { struct, recipient } of virtualRecipients) {
1298
+ if (!(struct.userId === user.id || struct.email === user.email) && !((struct.userId === null && struct.email === null))) {
1299
+ stripSensitiveRecipientReplacements(recipient, {
1300
+ organization,
1301
+ willFill: true,
1302
+ });
1303
+ }
1304
+
1305
+ recipient.email = user.email;
1306
+ recipient.userId = user.id;
1307
+
1308
+ // We always refresh the data when we display it on the web (so everything is up to date)
1309
+ await fillRecipientReplacements(recipient, {
1310
+ organization,
1311
+ from: this.getFromAddress(),
1312
+ replyTo: null,
1313
+ forPreview: false,
1314
+ forceRefresh: true,
1315
+ });
1316
+ stripRecipientReplacementsForWebDisplay(recipient, {
1317
+ organization,
1318
+ });
1319
+ struct.replacements = recipient.replacements;
1320
+ }
1321
+
1322
+ let organizationStruct: BaseOrganization | null = null;
1323
+ if (organization) {
1324
+ organizationStruct = organization.getBaseStructure();
1325
+ }
1326
+
1327
+ return EmailWithRecipients.create({
1328
+ ...this,
1329
+ organization: organizationStruct,
1330
+ recipients: structures,
1331
+
1332
+ // Remove private-like data
1333
+ softBouncesCount: 0,
1334
+ failedCount: 0,
1335
+ emailErrors: null,
1336
+ recipientsErrors: null,
1337
+ succeededCount: 1,
1338
+ emailRecipientsCount: 1,
1339
+ hardBouncesCount: 0,
1340
+ spamComplaintsCount: 0,
1341
+ recipientFilter: EmailRecipientFilter.create({}),
1342
+ membersCount: 1,
1343
+ otherRecipientsCount: 0,
1344
+ });
1345
+ }
1184
1346
  }
@@ -1,10 +1,12 @@
1
1
  import { column } from '@simonbackx/simple-database';
2
- import { EmailRecipient as EmailRecipientStruct, Recipient, Replacement } from '@stamhoofd/structures';
2
+ import { EmailRecipient as EmailRecipientStruct, Recipient, Replacement, TinyMember } from '@stamhoofd/structures';
3
3
  import { v4 as uuidv4 } from 'uuid';
4
4
 
5
5
  import { ArrayDecoder } from '@simonbackx/simple-encoding';
6
6
  import { QueryableModel } from '@stamhoofd/sql';
7
7
  import { SimpleErrors } from '@simonbackx/simple-errors';
8
+ import { Formatter } from '@stamhoofd/utility';
9
+ import { Member } from './Member';
8
10
 
9
11
  export class EmailRecipient extends QueryableModel {
10
12
  static table = 'email_recipients';
@@ -70,6 +72,12 @@ export class EmailRecipient extends QueryableModel {
70
72
  @column({ type: 'string', nullable: true })
71
73
  userId: string | null = null;
72
74
 
75
+ /**
76
+ * Won't get send if it is a duplicate of another email recipient.
77
+ */
78
+ @column({ type: 'string', nullable: true })
79
+ duplicateOfRecipientId: string | null = null;
80
+
73
81
  /**
74
82
  * Set when the email was send, but we received a hard bounce for this specific email
75
83
  * Contains the full output we received in the bounce
@@ -134,11 +142,32 @@ export class EmailRecipient extends QueryableModel {
134
142
  })
135
143
  updatedAt: Date;
136
144
 
137
- getStructure() {
145
+ async getStructure() {
146
+ return (await EmailRecipient.getStructures([this]))[0];
147
+ }
148
+
149
+ getStructureWithoutRelations() {
138
150
  return EmailRecipientStruct.create(this);
139
151
  }
140
152
 
153
+ static async getStructures(models: EmailRecipient[]) {
154
+ const memberIds = Formatter.uniqueArray(models.map(m => m.memberId).filter(m => m) as string[]);
155
+ const members = await Member.getByIDs(...memberIds);
156
+ return models.map((m) => {
157
+ const struct = EmailRecipientStruct.create(m);
158
+
159
+ if (m.memberId) {
160
+ const member = members.find(me => me.id === m.memberId);
161
+ if (member) {
162
+ struct.member = TinyMember.create(member);
163
+ }
164
+ }
165
+
166
+ return struct;
167
+ });
168
+ }
169
+
141
170
  getRecipient() {
142
- return this.getStructure().getRecipient();
171
+ return this.getStructureWithoutRelations().getRecipient();
143
172
  }
144
173
  }
@@ -315,7 +315,7 @@ export class Member extends QueryableModel {
315
315
  /**
316
316
  * Fetch all members with their corresponding (valid) registrations or waiting lists and payments
317
317
  */
318
- static async getMemberIdsWithRegistrationForUser(user: User): Promise<string[]> {
318
+ static async getMemberIdsForUser(user: User): Promise<string[]> {
319
319
  const query = SQL
320
320
  .select('id')
321
321
  .from(Member.table)
@@ -332,14 +332,14 @@ export class Member extends QueryableModel {
332
332
  );
333
333
 
334
334
  const data = await query.fetch();
335
- return data.map(r => r.members.id as string);
335
+ return Formatter.uniqueArray(data.map(r => r.members.id as string));
336
336
  }
337
337
 
338
338
  /**
339
339
  * Fetch all members with their corresponding (valid) registrations or waiting lists and payments
340
340
  */
341
341
  static async getMembersWithRegistrationForUser(user: User): Promise<MemberWithRegistrations[]> {
342
- return this.getBlobByIds(...(await this.getMemberIdsWithRegistrationForUser(user)));
342
+ return this.getBlobByIds(...(await this.getMemberIdsForUser(user)));
343
343
  }
344
344
 
345
345
  static getRegistrationWithTinyMemberStructure(registration: RegistrationWithMember & { group: import('./Group').Group }): RegistrationWithTinyMember {
@@ -13,6 +13,9 @@ export class RegistrationPeriod extends QueryableModel {
13
13
  })
14
14
  id!: string;
15
15
 
16
+ @column({ type: 'string', nullable: true })
17
+ customName: string | null = null;
18
+
16
19
  @column({ type: 'string', nullable: true })
17
20
  previousPeriodId: string | null = null;
18
21
 
@@ -228,13 +228,19 @@ export class User extends QueryableModel {
228
228
  await Database.update(query, [this.id, other.id]);
229
229
 
230
230
  // Update payments
231
- const query2 = 'UPDATE payments SET userId = ? WHERE userId = ?';
231
+ const query2 = 'UPDATE payments SET payingUserId = ? WHERE payingUserId = ?';
232
232
  await Database.update(query2, [this.id, other.id]);
233
233
 
234
234
  // Update orders
235
235
  const query3 = 'UPDATE webshop_orders SET userId = ? WHERE userId = ?';
236
236
  await Database.update(query3, [this.id, other.id]);
237
237
 
238
+ // Update emails
239
+ await Database.update('UPDATE emails SET userId = ? WHERE userId = ?', [this.id, other.id]);
240
+
241
+ // Update email recipients
242
+ await Database.update('UPDATE email_recipients SET userId = ? WHERE userId = ?', [this.id, other.id]);
243
+
238
244
  await other.delete();
239
245
  }
240
246