@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,59 @@
1
+ import { baseSQLFilterCompilers, createColumnFilter, SQL, SQLConcat, SQLFilterDefinitions, SQLScalar, SQLValueType } from '@stamhoofd/sql';
2
+
3
+ export const emailRecipientsFilterCompilers: SQLFilterDefinitions = {
4
+ ...baseSQLFilterCompilers,
5
+ id: createColumnFilter({
6
+ expression: SQL.column('id'),
7
+ type: SQLValueType.String,
8
+ nullable: false,
9
+ }),
10
+ email: createColumnFilter({
11
+ expression: SQL.column('email'),
12
+ type: SQLValueType.String,
13
+ nullable: true,
14
+ }),
15
+ name: createColumnFilter({
16
+ expression: new SQLConcat(
17
+ SQL.column('firstName'),
18
+ new SQLScalar(' '),
19
+ SQL.column('lastName'),
20
+ ),
21
+ type: SQLValueType.String,
22
+ nullable: true,
23
+ }),
24
+ organizationId: createColumnFilter({
25
+ expression: SQL.column('organizationId'),
26
+ type: SQLValueType.String,
27
+ nullable: true,
28
+ }),
29
+ emailId: createColumnFilter({
30
+ expression: SQL.column('emailId'),
31
+ type: SQLValueType.String,
32
+ nullable: false,
33
+ }),
34
+ sentAt: createColumnFilter({
35
+ expression: SQL.column('sentAt'),
36
+ type: SQLValueType.Datetime,
37
+ nullable: true,
38
+ }),
39
+ failError: createColumnFilter({
40
+ expression: SQL.column('failError'),
41
+ type: SQLValueType.JSONObject,
42
+ nullable: true,
43
+ }),
44
+ spamComplaintError: createColumnFilter({
45
+ expression: SQL.column('spamComplaintError'),
46
+ type: SQLValueType.String,
47
+ nullable: true,
48
+ }),
49
+ softBounceError: createColumnFilter({
50
+ expression: SQL.column('softBounceError'),
51
+ type: SQLValueType.String,
52
+ nullable: true,
53
+ }),
54
+ hardBounceError: createColumnFilter({
55
+ expression: SQL.column('hardBounceError'),
56
+ type: SQLValueType.String,
57
+ nullable: true,
58
+ }),
59
+ };
@@ -47,11 +47,36 @@ export const emailFilterCompilers: SQLFilterDefinitions = {
47
47
  type: SQLValueType.String,
48
48
  nullable: false,
49
49
  }),
50
- recipientCount: createColumnFilter({
51
- expression: SQL.column('recipientCount'),
50
+ emailRecipientsCount: createColumnFilter({
51
+ expression: SQL.column('emailRecipientsCount'),
52
52
  type: SQLValueType.Number,
53
53
  nullable: true,
54
54
  }),
55
+ failedCount: createColumnFilter({
56
+ expression: SQL.column('failedCount'),
57
+ type: SQLValueType.Number,
58
+ nullable: false,
59
+ }),
60
+ softFailedCount: createColumnFilter({
61
+ expression: SQL.column('softFailedCount'),
62
+ type: SQLValueType.Number,
63
+ nullable: false,
64
+ }),
65
+ hardBouncesCount: createColumnFilter({
66
+ expression: SQL.column('hardBouncesCount'),
67
+ type: SQLValueType.Number,
68
+ nullable: false,
69
+ }),
70
+ softBouncesCount: createColumnFilter({
71
+ expression: SQL.column('softBouncesCount'),
72
+ type: SQLValueType.Number,
73
+ nullable: false,
74
+ }),
75
+ spamComplaintsCount: createColumnFilter({
76
+ expression: SQL.column('spamComplaintsCount'),
77
+ type: SQLValueType.Number,
78
+ nullable: false,
79
+ }),
55
80
  createdAt: createColumnFilter({
56
81
  expression: SQL.column('createdAt'),
57
82
  type: SQLValueType.Datetime,
@@ -62,4 +87,14 @@ export const emailFilterCompilers: SQLFilterDefinitions = {
62
87
  type: SQLValueType.Datetime,
63
88
  nullable: true,
64
89
  }),
90
+ senderId: createColumnFilter({
91
+ expression: SQL.column('senderId'),
92
+ type: SQLValueType.String,
93
+ nullable: true,
94
+ }),
95
+ deletedAt: createColumnFilter({
96
+ expression: SQL.column('deletedAt'),
97
+ type: SQLValueType.Datetime,
98
+ nullable: true,
99
+ }),
65
100
  };
@@ -52,6 +52,16 @@ export const eventFilterCompilers: SQLFilterDefinitions = {
52
52
  type: SQLValueType.JSONArray,
53
53
  nullable: true,
54
54
  }),
55
+ 'minAge': createColumnFilter({
56
+ expression: SQL.jsonValue(SQL.column('meta'), '$.value.minAge'),
57
+ type: SQLValueType.Number,
58
+ nullable: true,
59
+ }),
60
+ 'maxAge': createColumnFilter({
61
+ expression: SQL.jsonValue(SQL.column('meta'), '$.value.maxAge'),
62
+ type: SQLValueType.Number,
63
+ nullable: true,
64
+ }),
55
65
  'meta.visible': createColumnFilter({
56
66
  expression: SQL.jsonValue(SQL.column('meta'), '$.value.visible'),
57
67
  type: SQLValueType.JSONBoolean,
@@ -1,5 +1,5 @@
1
1
  import { SimpleError } from '@simonbackx/simple-errors';
2
- import { Member } from '@stamhoofd/models';
2
+ import { Email, Member } from '@stamhoofd/models';
3
3
  import { baseSQLFilterCompilers, createColumnFilter, createExistsFilter, SQL, SQLAge, SQLCast, SQLConcat, SQLFilterDefinitions, SQLValueType, SQLScalar, createWildcardColumnFilter, SQLJsonExtract } from '@stamhoofd/sql';
4
4
  import { AccessRight } from '@stamhoofd/structures';
5
5
  import { Context } from '../helpers/Context';
@@ -413,6 +413,47 @@ export const memberFilterCompilers: SQLFilterDefinitions = {
413
413
  ),
414
414
  organizationFilterCompilers,
415
415
  ),
416
+ 'emails': createExistsFilter(
417
+ SQL.select()
418
+ .from(
419
+ SQL.table('email_recipients'),
420
+ ).where(
421
+ SQL.column('memberId'),
422
+ SQL.column('members', 'id'),
423
+ ),
424
+ {
425
+ ...baseSQLFilterCompilers,
426
+ id: createColumnFilter({
427
+ expression: SQL.column('emailId'),
428
+ type: SQLValueType.String,
429
+ nullable: false,
430
+ checkPermission: async (filter) => {
431
+ if (typeof filter !== 'string') {
432
+ throw new SimpleError({
433
+ code: 'invalid_filter',
434
+ message: 'This filter structure is not supported here.',
435
+ });
436
+ }
437
+ const id = filter;
438
+ const email = await Email.getByID(id);
439
+ if (!email) {
440
+ throw new SimpleError({
441
+ code: 'not_found',
442
+ message: 'This email does not exist.',
443
+ human: $t('Deze e-mail bestaat niet (meer)'),
444
+ statusCode: 404,
445
+ });
446
+ }
447
+ if (!await Context.auth.canAccessEmail(email)) {
448
+ throw Context.auth.error({
449
+ message: 'No permissions to access this email.',
450
+ human: $t('Je hebt niet voldoende toegangsrechten om te filteren op deze e-mail'),
451
+ });
452
+ }
453
+ },
454
+ }),
455
+ },
456
+ ),
416
457
  'details': {
417
458
  ...baseSQLFilterCompilers,
418
459
  recordAnswers: createWildcardColumnFilter(
@@ -7,6 +7,11 @@ export const registrationPeriodFilterCompilers: SQLFilterDefinitions = {
7
7
  type: SQLValueType.String,
8
8
  nullable: false,
9
9
  }),
10
+ organizationId: createColumnFilter({
11
+ expression: SQL.column('organizationId'),
12
+ type: SQLValueType.String,
13
+ nullable: false,
14
+ }),
10
15
  startDate: createColumnFilter({
11
16
  expression: SQL.column('startDate'),
12
17
  type: SQLValueType.Datetime,
@@ -0,0 +1,69 @@
1
+ import { EmailRecipient } from '@stamhoofd/models';
2
+ import { SQL, SQLOrderBy, SQLOrderByDirection, SQLSortDefinitions } from '@stamhoofd/sql';
3
+ import { Formatter } from '@stamhoofd/utility';
4
+
5
+ export const emailRecipientSorters: SQLSortDefinitions<EmailRecipient> = {
6
+ // WARNING! TEST NEW SORTERS THOROUGHLY!
7
+ // Try to avoid creating sorters on fields that er not 1:1 with the database, that often causes pagination issues if not thought through
8
+ // An example: sorting on 'name' is not a good idea, because it is a concatenation of two fields.
9
+ // You might be tempted to use ORDER BY firstName, lastName, but that will not work as expected and it needs to be ORDER BY CONCAT(firstName, ' ', lastName)
10
+ // Why? Because ORDER BY firstName, lastName produces a different order dan ORDER BY CONCAT(firstName, ' ', lastName) if there are multiple people with spaces in the first name
11
+ // And that again causes issues with pagination because the next query will append a filter of name > 'John Doe' - causing duplicate and/or skipped results
12
+ // What if you need mapping? simply map the sorters in the frontend: name -> firstname, lastname, age -> birthDay, etc.
13
+
14
+ id: {
15
+ getValue(a) {
16
+ return a.id;
17
+ },
18
+ toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
19
+ return new SQLOrderBy({
20
+ column: SQL.column('id'),
21
+ direction,
22
+ });
23
+ },
24
+ },
25
+ sentAt: {
26
+ getValue(a) {
27
+ return a.sentAt ? Formatter.dateTimeIso(a.sentAt, 'UTC') : null;
28
+ },
29
+ toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
30
+ return new SQLOrderBy({
31
+ column: SQL.column('sentAt'),
32
+ direction,
33
+ });
34
+ },
35
+ },
36
+ email: {
37
+ getValue(a) {
38
+ return a.email;
39
+ },
40
+ toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
41
+ return new SQLOrderBy({
42
+ column: SQL.column('email'),
43
+ direction,
44
+ });
45
+ },
46
+ },
47
+ firstName: {
48
+ getValue(a) {
49
+ return a.firstName;
50
+ },
51
+ toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
52
+ return new SQLOrderBy({
53
+ column: SQL.column('firstName'),
54
+ direction,
55
+ });
56
+ },
57
+ },
58
+ lastName: {
59
+ getValue(a) {
60
+ return a.lastName;
61
+ },
62
+ toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
63
+ return new SQLOrderBy({
64
+ column: SQL.column('lastName'),
65
+ direction,
66
+ });
67
+ },
68
+ },
69
+ };
@@ -1,55 +0,0 @@
1
- import { Migration } from '@simonbackx/simple-database';
2
- import { logger } from '@simonbackx/simple-logging';
3
- import { 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
- let c = 0;
13
- let id: string = '';
14
-
15
- await logger.setContext({ tags: ['seed'] }, async () => {
16
- while (true) {
17
- const items = await RegistrationPeriod.where({
18
- id: {
19
- value: id,
20
- sign: '>',
21
- },
22
- }, { limit: 1000, sort: ['id'] });
23
-
24
- if (items.length === 0) {
25
- break;
26
- }
27
-
28
- process.stdout.write('.');
29
-
30
- for (const item of items) {
31
- await item.setPreviousPeriodId();
32
- if (await item.save()) {
33
- c += 1;
34
- }
35
- }
36
-
37
- if (items.length < 1000) {
38
- break;
39
- }
40
- id = items[items.length - 1].id;
41
- }
42
- });
43
-
44
- console.log('Updated ' + c + ' registration periods');
45
-
46
- // Now update platform
47
- const platform = await Platform.getForEditing();
48
- await platform.setPreviousPeriodId();
49
- await platform.save();
50
-
51
- console.log('Updated platform');
52
-
53
- // Do something here
54
- return Promise.resolve();
55
- });