@stamhoofd/backend 2.92.0 → 2.94.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 (32) hide show
  1. package/package.json +10 -10
  2. package/src/audit-logs/EmailLogger.ts +2 -2
  3. package/src/crons/amazon-ses.ts +100 -4
  4. package/src/crons/balance-emails.ts +1 -1
  5. package/src/email-recipient-loaders/receivable-balances.ts +3 -1
  6. package/src/endpoints/global/email/CreateEmailEndpoint.ts +26 -2
  7. package/src/endpoints/global/email/GetAdminEmailsEndpoint.ts +2 -3
  8. package/src/endpoints/global/email/PatchEmailEndpoint.ts +27 -14
  9. package/src/endpoints/global/email-recipients/GetEmailRecipientsCountEndpoint.ts +47 -0
  10. package/src/endpoints/global/email-recipients/GetEmailRecipientsEndpoint.test.ts +225 -0
  11. package/src/endpoints/global/email-recipients/GetEmailRecipientsEndpoint.ts +164 -0
  12. package/src/endpoints/global/email-recipients/helpers/validateEmailRecipientFilter.ts +64 -0
  13. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +5 -1
  14. package/src/endpoints/global/registration-periods/GetRegistrationPeriodsEndpoint.ts +19 -1
  15. package/src/endpoints/global/registration-periods/PatchRegistrationPeriodsEndpoint.ts +4 -15
  16. package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.ts +2 -0
  17. package/src/helpers/AdminPermissionChecker.ts +4 -0
  18. package/src/helpers/AuthenticatedStructures.ts +1 -1
  19. package/src/helpers/EmailResumer.ts +2 -2
  20. package/src/seeds/1755790070-fill-email-recipient-errors.ts +96 -0
  21. package/src/seeds/1756115432-remove-old-drafts.ts +16 -0
  22. package/src/seeds/1756115433-fill-email-recipient-organization-id.ts +30 -0
  23. package/src/seeds/1756293699-fill-previous-next-period-id.ts +34 -0
  24. package/src/seeds/1756303697-update-email-counts.ts +76 -0
  25. package/src/sql-filters/email-recipients.ts +59 -0
  26. package/src/sql-filters/emails.ts +37 -2
  27. package/src/sql-filters/events.ts +10 -0
  28. package/src/sql-filters/members.ts +42 -1
  29. package/src/sql-filters/registration-periods.ts +5 -0
  30. package/src/sql-sorters/email-recipients.ts +69 -0
  31. package/src/seeds/1734596144-fill-previous-period-id.ts +0 -55
  32. /package/src/seeds/{1755181288-remove-duplicate-members.ts → 1755876819-remove-duplicate-members.ts} +0 -0
@@ -0,0 +1,164 @@
1
+ import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
2
+ import { assertSort, CountFilteredRequest, EmailRecipient as EmailRecipientStruct, getSortFilter, LimitedFilteredRequest, PaginatedResponse, PermissionLevel, StamhoofdFilter } from '@stamhoofd/structures';
3
+
4
+ import { Decoder } from '@simonbackx/simple-encoding';
5
+ import { SimpleError } from '@simonbackx/simple-errors';
6
+ import { EmailRecipient } from '@stamhoofd/models';
7
+ import { applySQLSorter, compileToSQLFilter, SQLFilterDefinitions, SQLSortDefinitions } from '@stamhoofd/sql';
8
+ import { Context } from '../../../helpers/Context';
9
+ import { emailRecipientsFilterCompilers } from '../../../sql-filters/email-recipients';
10
+ import { emailRecipientSorters } from '../../../sql-sorters/email-recipients';
11
+ import { validateEmailRecipientFilter } from './helpers/validateEmailRecipientFilter';
12
+
13
+ type Params = Record<string, never>;
14
+ type Query = LimitedFilteredRequest;
15
+ type Body = undefined;
16
+ type ResponseBody = PaginatedResponse<EmailRecipientStruct[], LimitedFilteredRequest>;
17
+
18
+ const filterCompilers: SQLFilterDefinitions = emailRecipientsFilterCompilers;
19
+ const sorters: SQLSortDefinitions<EmailRecipient> = emailRecipientSorters;
20
+
21
+ export class GetEmailRecipientsEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
22
+ queryDecoder = LimitedFilteredRequest as Decoder<LimitedFilteredRequest>;
23
+
24
+ protected doesMatch(request: Request): [true, Params] | [false] {
25
+ if (request.method !== 'GET') {
26
+ return [false];
27
+ }
28
+
29
+ const params = Endpoint.parseParameters(request.url, '/email-recipients', {});
30
+
31
+ if (params) {
32
+ return [true, params as Params];
33
+ }
34
+ return [false];
35
+ }
36
+
37
+ static async buildQuery(q: CountFilteredRequest | LimitedFilteredRequest) {
38
+ const organization = Context.organization;
39
+ let scopeFilter: StamhoofdFilter | undefined = undefined;
40
+
41
+ if (organization) {
42
+ scopeFilter = {
43
+ organizationId: organization.id,
44
+ };
45
+ }
46
+ const canReadAllEmails = await Context.auth.canReadAllEmails(organization ?? null);
47
+
48
+ if (!canReadAllEmails) {
49
+ // Check if scope is correctly limited to a single email, otherwise throw an error.
50
+ if (!await validateEmailRecipientFilter({ filter: q.filter, permissionLevel: PermissionLevel.Read })) {
51
+ throw Context.auth.error({
52
+ message: 'You do not have sufficient permissions to view all email recipients',
53
+ human: $t(`Je hebt niet voldoende toegangsrechten om alle email ontvangers te bekijken. Filter op één specifieke e-mail.`),
54
+ });
55
+ }
56
+ }
57
+
58
+ const query = EmailRecipient.select();
59
+
60
+ if (scopeFilter) {
61
+ query.where(await compileToSQLFilter(scopeFilter, filterCompilers));
62
+ }
63
+
64
+ if (q.filter) {
65
+ query.where(await compileToSQLFilter(q.filter, filterCompilers));
66
+ }
67
+
68
+ if (q.search) {
69
+ let searchFilter: StamhoofdFilter | null = null;
70
+
71
+ searchFilter = {
72
+ $or: [
73
+ {
74
+ email: {
75
+ $contains: q.search,
76
+ },
77
+ },
78
+ {
79
+ name: {
80
+ $contains: q.search,
81
+ },
82
+ },
83
+ ],
84
+ };
85
+
86
+ if (searchFilter) {
87
+ query.where(await compileToSQLFilter(searchFilter, filterCompilers));
88
+ }
89
+ }
90
+
91
+ if (q instanceof LimitedFilteredRequest) {
92
+ if (q.pageFilter) {
93
+ query.where(await compileToSQLFilter(q.pageFilter, filterCompilers));
94
+ }
95
+
96
+ q.sort = assertSort(q.sort, [{ key: 'id' }]);
97
+ applySQLSorter(query, q.sort, sorters);
98
+ query.limit(q.limit);
99
+ }
100
+
101
+ return query;
102
+ }
103
+
104
+ static async buildData(requestQuery: LimitedFilteredRequest) {
105
+ const query = await GetEmailRecipientsEndpoint.buildQuery(requestQuery);
106
+ const recipients = await query.fetch();
107
+
108
+ let next: LimitedFilteredRequest | undefined;
109
+
110
+ if (recipients.length >= requestQuery.limit) {
111
+ const lastObject = recipients[recipients.length - 1];
112
+ const nextFilter = getSortFilter(lastObject, sorters, requestQuery.sort);
113
+
114
+ next = new LimitedFilteredRequest({
115
+ filter: requestQuery.filter,
116
+ pageFilter: nextFilter,
117
+ sort: requestQuery.sort,
118
+ limit: requestQuery.limit,
119
+ search: requestQuery.search,
120
+ });
121
+
122
+ if (JSON.stringify(nextFilter) === JSON.stringify(requestQuery.pageFilter)) {
123
+ console.error('Found infinite loading loop for', requestQuery);
124
+ next = undefined;
125
+ }
126
+ }
127
+
128
+ return new PaginatedResponse<EmailRecipientStruct[], LimitedFilteredRequest>({
129
+ results: recipients.map(r => r.getStructure()),
130
+ next,
131
+ });
132
+ }
133
+
134
+ async handle(request: DecodedRequest<Params, Query, Body>) {
135
+ const organization = await Context.setOptionalOrganizationScope();
136
+ await Context.authenticate();
137
+
138
+ if (!await Context.auth.canReadEmails(organization)) {
139
+ throw Context.auth.error();
140
+ }
141
+
142
+ const maxLimit = Context.auth.hasSomePlatformAccess() ? 1000 : 100;
143
+
144
+ if (request.query.limit > maxLimit) {
145
+ throw new SimpleError({
146
+ code: 'invalid_field',
147
+ field: 'limit',
148
+ message: 'Limit can not be more than ' + maxLimit,
149
+ });
150
+ }
151
+
152
+ if (request.query.limit < 1) {
153
+ throw new SimpleError({
154
+ code: 'invalid_field',
155
+ field: 'limit',
156
+ message: 'Limit can not be less than 1',
157
+ });
158
+ }
159
+
160
+ return new Response(
161
+ await GetEmailRecipientsEndpoint.buildData(request.query),
162
+ );
163
+ }
164
+ }
@@ -0,0 +1,64 @@
1
+ import { SimpleError } from '@simonbackx/simple-errors';
2
+ import { Email } from '@stamhoofd/models';
3
+ import { FilterWrapperMarker, PermissionLevel, StamhoofdFilter, unwrapFilter, WrapperFilter } from '@stamhoofd/structures';
4
+ import { Context } from '../../../../helpers/Context';
5
+
6
+ export async function validateEmailRecipientFilter({ filter, permissionLevel }: { filter: StamhoofdFilter; permissionLevel: PermissionLevel }) {
7
+ // Require presence of a filter
8
+ const requiredFilter: WrapperFilter = {
9
+ emailId: FilterWrapperMarker,
10
+ };
11
+
12
+ const unwrapped = unwrapFilter(filter, requiredFilter);
13
+ if (!unwrapped.match) {
14
+ return false;
15
+ }
16
+
17
+ const emailIds = typeof unwrapped.markerValue === 'string'
18
+ ? [unwrapped.markerValue]
19
+ : unwrapFilter(unwrapped.markerValue as StamhoofdFilter, {
20
+ $in: FilterWrapperMarker,
21
+ })?.markerValue;
22
+
23
+ if (!Array.isArray(emailIds)) {
24
+ throw new SimpleError({
25
+ code: 'invalid_field',
26
+ field: 'filter',
27
+ message: 'You must filter on an email id of the email recipients you are trying to access',
28
+ human: $t(`Je hebt niet voldoende toegangsrechten om alle email ontvangers te bekijken.`),
29
+ });
30
+ }
31
+
32
+ if (emailIds.length === 0) {
33
+ throw new SimpleError({
34
+ code: 'invalid_field',
35
+ field: 'filter',
36
+ message: 'Filtering on an empty list of email ids is not supported',
37
+ });
38
+ }
39
+
40
+ for (const emailId of emailIds) {
41
+ if (typeof emailId !== 'string') {
42
+ throw new SimpleError({
43
+ code: 'invalid_field',
44
+ field: 'filter',
45
+ message: 'Invalid email ID in filter',
46
+ });
47
+ }
48
+ }
49
+
50
+ const emails = await Email.getByIDs(...emailIds as string[]);
51
+
52
+ console.log('Fetching recipients for emails', emails.map(g => g.subject));
53
+
54
+ for (const email of emails) {
55
+ if (!await Context.auth.canAccessEmail(email, permissionLevel)) {
56
+ throw Context.auth.error({
57
+ message: 'You do not have access to this email',
58
+ human: $t(`Je hebt geen toegangsrechten tot de ontvangers van deze email`),
59
+ });
60
+ }
61
+ }
62
+
63
+ return true;
64
+ }
@@ -966,7 +966,11 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
966
966
  }
967
967
 
968
968
  static shouldCheckIfMemberIsDuplicate(put: Member): boolean {
969
- if (put.details.firstName.length <= 3 && put.details.lastName.length <= 3) {
969
+ if (put.details.firstName === '???') {
970
+ return false;
971
+ }
972
+
973
+ if (put.details.name.length <= 3) {
970
974
  return false;
971
975
  }
972
976
 
@@ -5,6 +5,7 @@ import { Decoder } from '@simonbackx/simple-encoding';
5
5
  import { SimpleError } from '@simonbackx/simple-errors';
6
6
  import { RegistrationPeriod } from '@stamhoofd/models';
7
7
  import { applySQLSorter, compileToSQLFilter, SQLFilterDefinitions, SQLSortDefinitions } from '@stamhoofd/sql';
8
+ import { Context } from '../../../helpers/Context';
8
9
  import { registrationPeriodFilterCompilers } from '../../../sql-filters/registration-periods';
9
10
  import { registrationPeriodSorters } from '../../../sql-sorters/registration-periods';
10
11
 
@@ -33,9 +34,24 @@ export class GetRegistrationPeriodsEndpoint extends Endpoint<Params, Query, Body
33
34
  }
34
35
 
35
36
  static async buildQuery(q: CountFilteredRequest | LimitedFilteredRequest) {
36
- const scopeFilter: StamhoofdFilter | undefined = undefined;
37
+ let scopeFilter: StamhoofdFilter | undefined = undefined;
37
38
  const query = RegistrationPeriod.select();
38
39
 
40
+ if (STAMHOOFD.userMode === 'organization') {
41
+ const organization = Context.organization;
42
+
43
+ if (!organization) {
44
+ throw new SimpleError({
45
+ code: 'no_organization',
46
+ message: 'Organization is undefined on Context',
47
+ });
48
+ }
49
+
50
+ scopeFilter = {
51
+ organizationId: organization.id,
52
+ };
53
+ }
54
+
39
55
  if (scopeFilter) {
40
56
  query.where(await compileToSQLFilter(scopeFilter, filterCompilers));
41
57
  }
@@ -127,6 +143,8 @@ export class GetRegistrationPeriodsEndpoint extends Endpoint<Params, Query, Body
127
143
  });
128
144
  }
129
145
 
146
+ await Context.setUserOrganizationScope();
147
+
130
148
  return new Response(
131
149
  await GetRegistrationPeriodsEndpoint.buildData(request.query),
132
150
  );
@@ -92,9 +92,8 @@ export class PatchRegistrationPeriodsEndpoint extends Endpoint<Params, Query, Bo
92
92
  }
93
93
  }
94
94
 
95
- await period.setPreviousPeriodId();
96
-
97
95
  await period.save();
96
+ await period.updatePreviousNextPeriods();
98
97
  periods.push(period);
99
98
  }
100
99
 
@@ -138,7 +137,7 @@ export class PatchRegistrationPeriodsEndpoint extends Endpoint<Params, Query, Bo
138
137
  model.settings = patchObject(model.settings, patch.settings);
139
138
  }
140
139
 
141
- await model.setPreviousPeriodId();
140
+ await model.updatePreviousNextPeriods();
142
141
  await model.save();
143
142
 
144
143
  // Schedule patch of all groups in this period
@@ -156,23 +155,13 @@ export class PatchRegistrationPeriodsEndpoint extends Endpoint<Params, Query, Bo
156
155
  });
157
156
  }
158
157
 
159
- // Get before deleting the model
160
- const updateWhere = await RegistrationPeriod.where({ previousPeriodId: model.id });
161
-
162
158
  // Now delete the model
163
159
  await model.delete();
164
-
165
- // Update all previous period ids
166
- await AuditLogService.setContext({ source: AuditLogSource.System }, async () => {
167
- for (const period of updateWhere) {
168
- await period.setPreviousPeriodId();
169
- await period.save();
170
- }
171
- });
160
+ await RegistrationPeriod.updatePreviousNextPeriods(model.organizationId);
172
161
  }
173
162
 
174
163
  // Clear platform cache
175
- Platform.clearCache();
164
+ await Platform.clearCache();
176
165
 
177
166
  return new Response(
178
167
  periods.map(p => p.getStructure()),
@@ -531,6 +531,8 @@ export class PatchOrganizationRegistrationPeriodsEndpoint extends Endpoint<Param
531
531
  model.type = struct.type;
532
532
  model.settings.period = period.getBaseStructure();
533
533
  model.settings.endDate = period.endDate;
534
+ model.settings.registeredMembers = 0;
535
+ model.settings.reservedMembers = 0;
534
536
 
535
537
  // Note: start date is curomizable, as long as it stays between period start and end
536
538
  if (model.settings.startDate < period.startDate || model.settings.startDate > period.endDate) {
@@ -883,6 +883,10 @@ export class AdminPermissionChecker {
883
883
  return false;
884
884
  }
885
885
 
886
+ if (email.deletedAt) {
887
+ return false;
888
+ }
889
+
886
890
  if (email.userId === this.user.id) {
887
891
  // User can always read their own emails
888
892
  // Note; for sending we'll always need to use 'canSendEmailsFrom' externally
@@ -604,7 +604,7 @@ export class AuthenticatedStructures {
604
604
 
605
605
  const registration = memberBlob.registrations.find(r => r.id === id);
606
606
  if (!registration) {
607
- throw new Error('Registration not found');
607
+ throw new Error('Registration not found: ' + id);
608
608
  }
609
609
 
610
610
  return RegistrationWithMemberBlob.create({
@@ -6,7 +6,7 @@ import { ContextInstance } from './Context';
6
6
  export async function resumeEmails() {
7
7
  const query = SQL.select()
8
8
  .from(SQL.table(Email.table))
9
- .where(SQL.column('status'), EmailStatus.Sending);
9
+ .where(SQL.column('status'), [EmailStatus.Sending, EmailStatus.Queued]);
10
10
 
11
11
  const result = await query.fetch();
12
12
  const emails = Email.fromRows(result, Email.table);
@@ -28,7 +28,7 @@ export async function resumeEmails() {
28
28
 
29
29
  try {
30
30
  await ContextInstance.startForUser(user, organization, async () => {
31
- await email.send();
31
+ await email.resumeSending();
32
32
  });
33
33
  }
34
34
  catch (e) {
@@ -0,0 +1,96 @@
1
+ import { Migration } from '@simonbackx/simple-database';
2
+ import { SimpleError, SimpleErrors } from '@simonbackx/simple-errors';
3
+ import { EmailRecipient } from '@stamhoofd/models';
4
+
5
+ function stringToError(message: string) {
6
+ if (message === 'Recipient has hard bounced') {
7
+ return new SimpleErrors(
8
+ new SimpleError({
9
+ code: 'email_skipped_hard_bounce',
10
+ message: 'The recipient has hard bounced. This means that the email address is invalid or no longer exists.',
11
+ human: $t(`af49a569-ce88-48d9-ac37-81e594e16c03`),
12
+ }),
13
+ );
14
+ }
15
+
16
+ if (message === 'Recipient has marked as spam') {
17
+ return new SimpleErrors(
18
+ new SimpleError({
19
+ code: 'email_skipped_spam',
20
+ message: 'Recipient has marked as spam',
21
+ human: $t(`e6523f56-397e-4127-8bf7-8396f6f25a62`),
22
+ }),
23
+ );
24
+ }
25
+
26
+ if (message === 'Recipient has unsubscribed') {
27
+ return new SimpleErrors(
28
+ new SimpleError({
29
+ code: 'email_skipped_unsubscribed',
30
+ message: 'Recipient has unsubscribed',
31
+ human: $t('De ontvanger heeft zich afgemeld voor e-mails'),
32
+ }),
33
+ );
34
+ }
35
+
36
+ if (message === 'Recipient has unsubscribed from marketing') {
37
+ return new SimpleErrors(
38
+ new SimpleError({
39
+ code: 'email_skipped_unsubscribed',
40
+ message: 'Recipient has unsubscribed from marketing',
41
+ human: $t('De ontvanger heeft zich afgemeld voor e-mails'),
42
+ }),
43
+ );
44
+ }
45
+
46
+ if (message === 'All recipients are filtered due to hard bounce or spam') {
47
+ return new SimpleErrors(
48
+ new SimpleError({
49
+ code: 'all_filtered',
50
+ message: 'All recipients are filtered due to hard bounce or spam',
51
+ human: $t('Deze ontvanger komt voor op de gedeelde bounce of spamlijst. De ontvanger was eerder permanent onbereikbaar of heeft eerder een e-mail als spam gemarkeerd.'),
52
+ }),
53
+ );
54
+ }
55
+
56
+ if (message === 'Invalid email address') {
57
+ return new SimpleErrors(
58
+ new SimpleError({
59
+ code: 'invalid_email_address',
60
+ message: 'Invalid email address',
61
+ human: $t(`cbbff442-758c-4f76-b8c2-26bb176fefcc`),
62
+ }),
63
+ );
64
+ }
65
+
66
+ return new SimpleErrors(
67
+ new SimpleError({
68
+ code: 'unknown_error',
69
+ message: message,
70
+ }),
71
+ );
72
+ }
73
+
74
+ export default new Migration(async () => {
75
+ if (STAMHOOFD.environment === 'test') {
76
+ console.log('skipped in tests');
77
+ return;
78
+ }
79
+
80
+ console.log('Start setting failError object on email recipients.');
81
+
82
+ const batchSize = 100;
83
+ let count = 0;
84
+
85
+ for await (const r of EmailRecipient.select()
86
+ .whereNot('failErrorMessage', null).limit(batchSize).all()) {
87
+ if (!r.failErrorMessage) {
88
+ continue;
89
+ }
90
+ r.failError = stringToError(r.failErrorMessage);
91
+ await r.save();
92
+ count++;
93
+ }
94
+
95
+ console.log('Finished saving ' + count + ' recipients with error object.');
96
+ });
@@ -0,0 +1,16 @@
1
+ import { Migration } from '@simonbackx/simple-database';
2
+ import { Email } from '@stamhoofd/models';
3
+ import { EmailStatus } from '@stamhoofd/structures';
4
+
5
+ export default new Migration(async () => {
6
+ if (STAMHOOFD.environment === 'test') {
7
+ console.log('skipped in tests');
8
+ return;
9
+ }
10
+
11
+ const { affectedRows } = await Email.delete()
12
+ .where('status', EmailStatus.Draft)
13
+ .where('createdAt', '<', new Date(Date.now() - 1000 * 60 * 60 * 24 * 30)) // older than 30 days
14
+ .delete();
15
+ console.log('Deleted ' + affectedRows + ' old draft emails.');
16
+ });
@@ -0,0 +1,30 @@
1
+ import { Migration } from '@simonbackx/simple-database';
2
+ import { Email, EmailRecipient } from '@stamhoofd/models';
3
+ import { SQL } from '@stamhoofd/sql';
4
+
5
+ export default new Migration(async () => {
6
+ if (STAMHOOFD.environment === 'test') {
7
+ console.log('skipped in tests');
8
+ return;
9
+ }
10
+
11
+ console.log('Start setting organizationId object on email recipients.');
12
+
13
+ const batchSize = 100;
14
+ let count = 0;
15
+
16
+ for await (const email of Email.select().where('organizationId', '!=', null).limit(batchSize).all()) {
17
+ if (!email.organizationId) {
18
+ continue;
19
+ }
20
+
21
+ await SQL.update(EmailRecipient.table)
22
+ .set('organizationId', email.organizationId)
23
+ .where('emailId', email.id)
24
+ .update();
25
+
26
+ count++;
27
+ }
28
+
29
+ console.log('Finished saving email recipients of ' + count + ' emails with organization id.');
30
+ });
@@ -0,0 +1,34 @@
1
+ import { Migration } from '@simonbackx/simple-database';
2
+ import { logger } from '@simonbackx/simple-logging';
3
+ import { Organization, Platform, RegistrationPeriod } from '@stamhoofd/models';
4
+
5
+ export default new Migration(async () => {
6
+ if (STAMHOOFD.environment == 'test') {
7
+ console.log('skipped in tests');
8
+ return;
9
+ }
10
+
11
+ process.stdout.write('\n');
12
+
13
+ await logger.setContext({ tags: ['seed'] }, async () => {
14
+ if (STAMHOOFD.userMode === 'platform') {
15
+ await RegistrationPeriod.updatePreviousNextPeriods(null);
16
+ }
17
+ else {
18
+ for await (const organization of Organization.select().all()) {
19
+ await RegistrationPeriod.updatePreviousNextPeriods(organization.id);
20
+ }
21
+ }
22
+ });
23
+ console.log('Updated periods');
24
+
25
+ // Now update platform
26
+ const platform = await Platform.getForEditing();
27
+ await platform.setPreviousPeriodId();
28
+ await platform.save();
29
+
30
+ console.log('Updated platform');
31
+
32
+ // Do something here
33
+ return Promise.resolve();
34
+ });
@@ -0,0 +1,76 @@
1
+ import { Migration } from '@simonbackx/simple-database';
2
+ import { Email, EmailRecipient } from '@stamhoofd/models';
3
+ import { SQL, SQLAlias, SQLCount, SQLSelectAs } from '@stamhoofd/sql';
4
+ import { EmailStatus } from '@stamhoofd/structures';
5
+
6
+ export default new Migration(async () => {
7
+ if (STAMHOOFD.environment === 'test') {
8
+ console.log('skipped in tests');
9
+ return;
10
+ }
11
+
12
+ console.log('Start setting counts of emails.');
13
+
14
+ const batchSize = 100;
15
+ let count = 0;
16
+
17
+ for await (const email of Email.select()
18
+ .where('status', EmailStatus.Sent)
19
+ .where('succeededCount', 0)
20
+ .where('failedCount', 0)
21
+ .where('emailRecipientsCount', '!=', 0)
22
+ .where('emailRecipientsCount', '!=', null)
23
+ .where('createdAt', '<', new Date('2025-08-28 00:00:00')).limit(batchSize).all()) {
24
+ const query = SQL.select(
25
+ new SQLSelectAs(
26
+ new SQLCount(
27
+ SQL.column('failError'),
28
+ ),
29
+ new SQLAlias('data__failedCount'),
30
+ ),
31
+ // If the current amount_due is negative, we can ignore that negative part if there is a future due item
32
+ new SQLSelectAs(
33
+ new SQLCount(
34
+ SQL.column('sentAt'),
35
+ ),
36
+ new SQLAlias('data__succeededCount'),
37
+ ),
38
+ )
39
+ .from(EmailRecipient.table)
40
+ .where('emailId', email.id);
41
+
42
+ const result = await query.fetch();
43
+ if (result.length !== 1) {
44
+ console.error('Unexpected result', result);
45
+ continue;
46
+ }
47
+ const row = result[0]['data'];
48
+ if (!row) {
49
+ console.error('Unexpected result row', result);
50
+ continue;
51
+ }
52
+
53
+ let failedCount = row['failedCount'];
54
+ const succeededCount = row['succeededCount'];
55
+
56
+ if (typeof failedCount !== 'number' || typeof succeededCount !== 'number') {
57
+ console.error('Unexpected result values', row);
58
+ return;
59
+ }
60
+
61
+ if (email.emailRecipientsCount !== null && failedCount + succeededCount !== email.emailRecipientsCount) {
62
+ console.warn(`Email ${email.id} has ${email.emailRecipientsCount} recipients, but ${failedCount} failed and ${succeededCount} succeeded. Correcting failedCount to `, email.emailRecipientsCount - succeededCount);
63
+ failedCount = email.emailRecipientsCount - succeededCount;
64
+ }
65
+
66
+ // Send an update query
67
+ await Email.update()
68
+ .where('id', email.id)
69
+ .set('succeededCount', succeededCount)
70
+ .set('failedCount', failedCount)
71
+ .update();
72
+ count += 1;
73
+ }
74
+
75
+ console.log('Finished saving ' + count + ' emails.');
76
+ });