@stamhoofd/backend 2.83.4 → 2.84.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/index.ts +19 -4
  2. package/package.json +18 -14
  3. package/src/crons/amazon-ses.ts +26 -5
  4. package/src/crons/balance-emails.ts +18 -17
  5. package/src/email-recipient-loaders/registrations.ts +87 -0
  6. package/src/endpoints/global/addresses/SearchRegionsEndpoint.ts +5 -2
  7. package/src/endpoints/global/email/PatchEmailEndpoint.test.ts +40 -40
  8. package/src/endpoints/global/events/PatchEventNotificationsEndpoint.test.ts +28 -22
  9. package/src/endpoints/global/events/PatchEventsEndpoint.ts +81 -49
  10. package/src/endpoints/global/files/UploadFile.ts +11 -16
  11. package/src/endpoints/global/groups/GetGroupsEndpoint.test.ts +234 -0
  12. package/src/endpoints/global/groups/GetGroupsEndpoint.ts +117 -43
  13. package/src/endpoints/global/members/GetMembersEndpoint.test.ts +1054 -0
  14. package/src/endpoints/global/members/GetMembersEndpoint.ts +163 -141
  15. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.test.ts +6 -6
  16. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +0 -16
  17. package/src/endpoints/global/members/helpers/validateGroupFilter.ts +73 -0
  18. package/src/endpoints/global/registration/GetPaymentRegistrations.ts +1 -2
  19. package/src/endpoints/global/registration/GetRegistrationsCountEndpoint.ts +43 -0
  20. package/src/endpoints/global/registration/GetRegistrationsEndpoint.test.ts +1016 -0
  21. package/src/endpoints/global/registration/GetRegistrationsEndpoint.ts +234 -0
  22. package/src/endpoints/global/registration/PatchUserMembersEndpoint.test.ts +5 -5
  23. package/src/endpoints/global/registration/RegisterMembersEndpoint.test.ts +474 -554
  24. package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +191 -52
  25. package/src/endpoints/global/registration-periods/GetRegistrationPeriodsEndpoint.ts +107 -9
  26. package/src/endpoints/organization/dashboard/email-templates/GetEmailTemplatesEndpoint.test.ts +89 -0
  27. package/src/endpoints/organization/dashboard/email-templates/GetEmailTemplatesEndpoint.ts +9 -6
  28. package/src/endpoints/organization/dashboard/email-templates/PatchEmailTemplatesEndpoint.test.ts +88 -0
  29. package/src/endpoints/organization/dashboard/email-templates/PatchEmailTemplatesEndpoint.ts +0 -6
  30. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +10 -6
  31. package/src/endpoints/organization/dashboard/payments/GetMemberBalanceEndpoint.ts +10 -25
  32. package/src/endpoints/organization/dashboard/payments/PatchBalanceItemsEndpoint.ts +0 -5
  33. package/src/endpoints/organization/dashboard/payments/PatchPaymentsEndpoint.ts +0 -5
  34. package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalanceEndpoint.ts +4 -0
  35. package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalancesEndpoint.ts +1 -0
  36. package/src/endpoints/organization/dashboard/registration-periods/GetOrganizationRegistrationPeriodsEndpoint.test.ts +44 -19
  37. package/src/endpoints/organization/dashboard/registration-periods/GetOrganizationRegistrationPeriodsEndpoint.ts +140 -25
  38. package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.ts +40 -10
  39. package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.test.ts +2 -2
  40. package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.test.ts +2 -2
  41. package/src/endpoints/organization/dashboard/webshops/PatchWebshopEndpoint.ts +4 -1
  42. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +2 -2
  43. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +2 -2
  44. package/src/excel-loaders/members.ts +233 -232
  45. package/src/excel-loaders/payments.ts +1 -1
  46. package/src/excel-loaders/receivable-balances.ts +1 -1
  47. package/src/excel-loaders/registrations.ts +153 -0
  48. package/src/helpers/AdminPermissionChecker.ts +65 -37
  49. package/src/helpers/AuthenticatedStructures.ts +43 -3
  50. package/src/helpers/Context.ts +29 -1
  51. package/src/helpers/GlobalHelper.ts +3 -1
  52. package/src/helpers/GroupedThrottledQueue.test.ts +219 -0
  53. package/src/helpers/GroupedThrottledQueue.ts +108 -0
  54. package/src/helpers/LimitedFilteredRequestHelper.ts +26 -1
  55. package/src/helpers/MemberCharger.ts +0 -5
  56. package/src/helpers/MembershipCharger.ts +3 -9
  57. package/src/helpers/OrganizationCharger.ts +0 -5
  58. package/src/helpers/ThrottledQueue.test.ts +194 -0
  59. package/src/helpers/ThrottledQueue.ts +145 -0
  60. package/src/helpers/XlsxTransformerColumnHelper.ts +44 -1
  61. package/src/middleware/ContextMiddleware.ts +1 -1
  62. package/src/seeds/1728928974-update-cached-outstanding-balance-from-items.ts +2 -1
  63. package/src/seeds/1735577912-update-cached-outstanding-balance-from-items.ts +2 -1
  64. package/src/services/BalanceItemPaymentService.ts +1 -33
  65. package/src/services/BalanceItemService.ts +167 -48
  66. package/src/services/FileSignService.ts +18 -13
  67. package/src/services/MemberRecordStore.ts +28 -19
  68. package/src/services/PaymentReallocationService.test.ts +25 -14
  69. package/src/services/PaymentReallocationService.ts +29 -10
  70. package/src/services/PaymentService.ts +4 -16
  71. package/src/services/PlatformMembershipService.ts +8 -4
  72. package/src/services/RegistrationService.ts +66 -2
  73. package/src/sql-filters/base-registration-filter-compilers.ts +43 -0
  74. package/src/sql-filters/groups.ts +67 -0
  75. package/src/sql-filters/members.ts +33 -58
  76. package/src/sql-filters/organization-registration-periods.ts +8 -0
  77. package/src/sql-filters/registration-periods.ts +8 -0
  78. package/src/sql-filters/registrations.ts +11 -22
  79. package/src/sql-sorters/groups.ts +24 -0
  80. package/src/sql-sorters/organization-registration-periods.ts +24 -0
  81. package/src/sql-sorters/registration-periods.ts +47 -0
  82. package/src/sql-sorters/registrations.ts +77 -0
  83. package/tests/actions/patchOrganizationMember.ts +27 -0
  84. package/tests/actions/patchPaymentStatus.ts +45 -0
  85. package/tests/actions/patchUserMember.ts +27 -0
  86. package/tests/assertions/assertBalances.ts +49 -0
  87. package/tests/e2e/api-rate-limits.test.ts +5 -5
  88. package/tests/e2e/bundle-discounts.test.ts +4060 -0
  89. package/tests/e2e/charge-members.test.ts +27 -24
  90. package/tests/e2e/documents.test.ts +398 -0
  91. package/tests/e2e/register.test.ts +292 -312
  92. package/tests/helpers/PayconiqMocker.ts +55 -0
  93. package/tests/init/index.ts +5 -0
  94. package/tests/init/initAdmin.ts +14 -0
  95. package/tests/init/initBundleDiscount.ts +47 -0
  96. package/tests/init/initPayconiq.ts +9 -0
  97. package/tests/init/initPlatformAdmin.ts +13 -0
  98. package/tests/init/initStripe.ts +21 -0
  99. package/tests/jest.setup.ts +29 -0
  100. package/src/seeds-temporary/1736266448-recall-balance-item-price-paid.ts +0 -70
@@ -1,9 +1,9 @@
1
1
  import { Decoder } from '@simonbackx/simple-encoding';
2
2
  import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
3
3
  import { SimpleError } from '@simonbackx/simple-errors';
4
- import { Member, Platform } from '@stamhoofd/models';
4
+ import { Group, Member, Platform } from '@stamhoofd/models';
5
5
  import { SQL, applySQLSorter, compileToSQLFilter } from '@stamhoofd/sql';
6
- import { CountFilteredRequest, Country, CountryCode, LimitedFilteredRequest, MembersBlob, PaginatedResponse, PermissionLevel, StamhoofdFilter, assertSort, getSortFilter } from '@stamhoofd/structures';
6
+ import { CountFilteredRequest, Country, CountryCode, GroupType, LimitedFilteredRequest, MembersBlob, PaginatedResponse, PermissionLevel, StamhoofdFilter, assertSort, getSortFilter } from '@stamhoofd/structures';
7
7
  import { DataValidator } from '@stamhoofd/utility';
8
8
 
9
9
  import { SQLResultNamespacedRow } from '@simonbackx/simple-database';
@@ -12,6 +12,7 @@ import { AuthenticatedStructures } from '../../../helpers/AuthenticatedStructure
12
12
  import { Context } from '../../../helpers/Context';
13
13
  import { memberFilterCompilers } from '../../../sql-filters/members';
14
14
  import { memberSorters } from '../../../sql-sorters/members';
15
+ import { validateGroupFilter } from './helpers/validateGroupFilter';
15
16
 
16
17
  type Params = Record<string, never>;
17
18
  type Query = LimitedFilteredRequest;
@@ -41,75 +42,88 @@ export class GetMembersEndpoint extends Endpoint<Params, Query, Body, ResponseBo
41
42
  const organization = Context.organization;
42
43
  let scopeFilter: StamhoofdFilter | undefined = undefined;
43
44
 
44
- if (!organization && !Context.auth.canAccessAllPlatformMembers()) {
45
- const tags = Context.auth.getPlatformAccessibleOrganizationTags(permissionLevel);
46
- if (tags !== 'all' && tags.length === 0) {
47
- throw Context.auth.error();
48
- }
49
-
50
- if (tags !== 'all') {
51
- const platform = await Platform.getShared();
52
-
53
- // Add organization scope filter
54
- scopeFilter = {
55
- registrations: {
56
- $elemMatch: {
57
- organization: {
58
- tags: {
59
- $in: tags,
60
- },
61
- },
62
- periodId: platform.periodId,
63
- },
64
- },
65
- };
66
- }
67
- }
68
-
69
- if (organization && !Context.auth.canAccessAllPlatformMembers()) {
70
- // Add organization scope filter
71
- const groups = await Context.auth.getAccessibleGroups(organization.id, permissionLevel);
45
+ // First do a quick validation of the groups, so that prevents the backend from having to add a scope filter
46
+ if (!Context.auth.canAccessAllPlatformMembers() && !await validateGroupFilter({ filter: q.filter, permissionLevel, key: 'registrations' })) {
47
+ if (!organization) {
48
+ const tags = Context.auth.getPlatformAccessibleOrganizationTags(permissionLevel);
49
+ if (tags !== 'all' && tags.length === 0) {
50
+ throw Context.auth.error();
51
+ }
72
52
 
73
- if (groups.length === 0) {
74
- throw Context.auth.error();
75
- }
53
+ if (tags !== 'all') {
54
+ const platform = await Platform.getShared();
76
55
 
77
- if (groups === 'all') {
78
- if (await Context.auth.hasFullAccess(organization.id, permissionLevel)) {
79
- // Can access full history for now
56
+ // Add organization scope filter
80
57
  scopeFilter = {
81
58
  registrations: {
82
59
  $elemMatch: {
83
- organizationId: organization.id,
60
+ organization: {
61
+ tags: {
62
+ $in: tags,
63
+ },
64
+ },
65
+ periodId: platform.periodId,
84
66
  },
85
67
  },
86
68
  };
87
69
  }
70
+ }
71
+
72
+ if (organization) {
73
+ // Add organization scope filter
74
+ if (await Context.auth.canAccessAllMembers(organization.id, permissionLevel)) {
75
+ if (await Context.auth.hasFullAccess(organization.id, permissionLevel)) {
76
+ // Can access full history for now
77
+ scopeFilter = {
78
+ registrations: {
79
+ $elemMatch: {
80
+ organizationId: organization.id,
81
+ },
82
+ },
83
+ };
84
+ }
85
+ else {
86
+ // Can only access current period
87
+ scopeFilter = {
88
+ registrations: {
89
+ $elemMatch: {
90
+ organizationId: organization.id,
91
+ periodId: organization.periodId,
92
+ },
93
+ },
94
+ };
95
+ }
96
+ }
88
97
  else {
89
- // Can only access current period
98
+ // Check which normal membership groups we have access to and filter on those
99
+ const groups = await Group.getAll(organization.id, organization.periodId, true, [GroupType.Membership, GroupType.WaitingList]);
100
+ Context.auth.cacheGroups(groups);
101
+ const groupIds: string[] = [];
102
+
103
+ for (const group of groups) {
104
+ if (await Context.auth.canAccessGroup(group, permissionLevel)) {
105
+ groupIds.push(group.id);
106
+ }
107
+ }
108
+
109
+ if (groupIds.length === 0) {
110
+ throw Context.auth.error({
111
+ message: 'You must filter on a group of the organization you are trying to access',
112
+ human: $t(`737f9ff3-8eca-40f5-8f03-d303d53de5d1`),
113
+ });
114
+ }
115
+
90
116
  scopeFilter = {
91
117
  registrations: {
92
118
  $elemMatch: {
93
- organizationId: organization.id,
94
- periodId: organization.periodId,
119
+ groupId: {
120
+ $in: groupIds,
121
+ },
95
122
  },
96
123
  },
97
124
  };
98
125
  }
99
126
  }
100
- else {
101
- scopeFilter = {
102
- registrations: {
103
- $elemMatch: {
104
- organizationId: organization.id,
105
- periodId: organization.periodId,
106
- groupId: {
107
- $in: groups,
108
- },
109
- },
110
- },
111
- };
112
- }
113
127
  }
114
128
 
115
129
  const query = SQL
@@ -129,95 +143,10 @@ export class GetMembersEndpoint extends Endpoint<Params, Query, Body, ResponseBo
129
143
  query.where(await compileToSQLFilter(q.filter, filterCompilers));
130
144
  }
131
145
 
132
- if (q.search) {
133
- let searchFilter: StamhoofdFilter | null = null;
134
-
135
- // is phone?
136
- if (!searchFilter && q.search.match(/^\+?[0-9\s-]+$/)) {
137
- // Try to format as phone so we have 1:1 space matches
138
- try {
139
- const country = (Context.i18n.country as CountryCode) || Country.Belgium;
140
-
141
- const phoneNumber = parsePhoneNumber(q.search, country);
142
- if (phoneNumber && phoneNumber.isValid()) {
143
- const formatted = phoneNumber.formatInternational();
144
- searchFilter = {
145
- $or: [
146
- {
147
- phone: {
148
- $eq: formatted,
149
- },
150
- },
151
- {
152
- parentPhone: {
153
- $eq: formatted,
154
- },
155
- },
156
- {
157
- unverifiedPhone: {
158
- $eq: formatted,
159
- },
160
- },
161
- ],
162
- };
163
- }
164
- }
165
- catch (e) {
166
- console.error('Failed to parse phone number', q.search, e);
167
- }
168
- }
169
-
170
- // Is lidnummer?
171
- if (!searchFilter && (q.search.match(/^[0-9]{4}-[0-9]{6}-[0-9]{1,2}$/) || q.search.match(/^[0-9]{9,10}$/))) {
172
- searchFilter = {
173
- memberNumber: {
174
- $eq: q.search,
175
- },
176
- };
177
- }
178
-
179
- // Two search modes:
180
- // e-mail or name based searching
181
- if (searchFilter) {
182
- // already done
183
- }
184
- else if (q.search.includes('@')) {
185
- const isCompleteAddress = DataValidator.isEmailValid(q.search);
186
-
187
- // Member email address contains, or member parent contains
188
- searchFilter = {
189
- $or: [
190
- {
191
- email: {
192
- [(isCompleteAddress ? '$eq' : '$contains')]: q.search,
193
- },
194
- },
195
- {
196
- parentEmail: {
197
- [(isCompleteAddress ? '$eq' : '$contains')]: q.search,
198
- },
199
- },
200
- {
201
- unverifiedEmail: {
202
- [(isCompleteAddress ? '$eq' : '$contains')]: q.search,
203
- },
204
- },
205
- ],
206
- } as any as StamhoofdFilter;
207
- }
208
- else {
209
- searchFilter = {
210
- name: {
211
- $contains: q.search,
212
- },
213
- };
214
- }
215
-
216
- // todo: Address search detection
146
+ const searchFilter = GetMembersEndpoint.buildSearchFilter(q.search);
217
147
 
218
- if (searchFilter) {
219
- query.where(await compileToSQLFilter(searchFilter, filterCompilers));
220
- }
148
+ if (searchFilter) {
149
+ query.where(await compileToSQLFilter(searchFilter, filterCompilers));
221
150
  }
222
151
 
223
152
  if (q instanceof LimitedFilteredRequest) {
@@ -233,6 +162,99 @@ export class GetMembersEndpoint extends Endpoint<Params, Query, Body, ResponseBo
233
162
  return query;
234
163
  }
235
164
 
165
+ static buildSearchFilter(search: string | null): StamhoofdFilter | null {
166
+ if (!search) {
167
+ return null;
168
+ }
169
+
170
+ let searchFilter: StamhoofdFilter | null = null;
171
+
172
+ // is phone?
173
+ if (!searchFilter && search.match(/^\+?[0-9\s-]+$/)) {
174
+ // Try to format as phone so we have 1:1 space matches
175
+ try {
176
+ const country = (Context.i18n.country as CountryCode) || Country.Belgium;
177
+
178
+ const phoneNumber = parsePhoneNumber(search, country);
179
+ if (phoneNumber && phoneNumber.isValid()) {
180
+ const formatted = phoneNumber.formatInternational();
181
+ searchFilter = {
182
+ $or: [
183
+ {
184
+ phone: {
185
+ $eq: formatted,
186
+ },
187
+ },
188
+ {
189
+ parentPhone: {
190
+ $eq: formatted,
191
+ },
192
+ },
193
+ {
194
+ unverifiedPhone: {
195
+ $eq: formatted,
196
+ },
197
+ },
198
+ ],
199
+ };
200
+ }
201
+ }
202
+ catch (e) {
203
+ console.error('Failed to parse phone number', search, e);
204
+ }
205
+ }
206
+
207
+ // Is lidnummer?
208
+ if (!searchFilter && (search.match(/^[0-9]{4}-[0-9]{6}-[0-9]{1,2}$/) || search.match(/^[0-9]{9,10}$/))) {
209
+ searchFilter = {
210
+ memberNumber: {
211
+ $eq: search,
212
+ },
213
+ };
214
+ }
215
+
216
+ // Two search modes:
217
+ // e-mail or name based searching
218
+ if (searchFilter) {
219
+ // already done
220
+ }
221
+ else if (search.includes('@')) {
222
+ const isCompleteAddress = DataValidator.isEmailValid(search);
223
+
224
+ // Member email address contains, or member parent contains
225
+ searchFilter = {
226
+ $or: [
227
+ {
228
+ email: {
229
+ [(isCompleteAddress ? '$eq' : '$contains')]: search,
230
+ },
231
+ },
232
+ {
233
+ parentEmail: {
234
+ [(isCompleteAddress ? '$eq' : '$contains')]: search,
235
+ },
236
+ },
237
+ {
238
+ unverifiedEmail: {
239
+ [(isCompleteAddress ? '$eq' : '$contains')]: search,
240
+ },
241
+ },
242
+ ],
243
+ } as any as StamhoofdFilter;
244
+ }
245
+ else {
246
+ searchFilter = {
247
+ name: {
248
+ $contains: search,
249
+ },
250
+ };
251
+ }
252
+
253
+ // todo: Address search detection
254
+
255
+ return searchFilter;
256
+ }
257
+
236
258
  static async buildData(requestQuery: LimitedFilteredRequest, permissionLevel = PermissionLevel.Read) {
237
259
  const query = await GetMembersEndpoint.buildQuery(requestQuery, permissionLevel);
238
260
  let data: SQLResultNamespacedRow[];
@@ -3,7 +3,7 @@ import { PatchableArray, PatchableArrayAutoEncoder, PatchMap } from '@simonbackx
3
3
  import { Endpoint, Request } from '@simonbackx/simple-endpoints';
4
4
  import { GroupFactory, MemberFactory, OrganizationFactory, OrganizationTagFactory, Platform, RegistrationFactory, Token, UserFactory } from '@stamhoofd/models';
5
5
  import { Address, Country, EmergencyContact, MemberDetails, MemberWithRegistrationsBlob, OrganizationMetaData, OrganizationRecordsConfiguration, Parent, PatchAnswers, PermissionLevel, Permissions, PermissionsResourceType, RecordCategory, RecordSettings, RecordTextAnswer, ResourcePermissions, ReviewTime, ReviewTimes, TranslatedString } from '@stamhoofd/structures';
6
- import { SHExpect, TestUtils } from '@stamhoofd/test-utils';
6
+ import { STExpect, TestUtils } from '@stamhoofd/test-utils';
7
7
  import { testServer } from '../../../../tests/helpers/TestServer';
8
8
  import { PatchOrganizationMembersEndpoint } from './PatchOrganizationMembersEndpoint';
9
9
 
@@ -57,7 +57,7 @@ describe('Endpoint.PatchOrganizationMembersEndpoint', () => {
57
57
  request.headers.authorization = 'Bearer ' + token.accessToken;
58
58
  await expect(testServer.test(endpoint, request))
59
59
  .rejects
60
- .toThrow(SHExpect.errorWithCode('known_member_missing_rights'));
60
+ .toThrow(STExpect.errorWithCode('known_member_missing_rights'));
61
61
  });
62
62
 
63
63
  test('The security code is not a requirement for members without additional data', async () => {
@@ -217,7 +217,7 @@ describe('Endpoint.PatchOrganizationMembersEndpoint', () => {
217
217
 
218
218
  const request = Request.buildJson('PATCH', baseUrl, organization.getApiHost(), arr);
219
219
  request.headers.authorization = 'Bearer ' + token.accessToken;
220
- await expect(testServer.test(endpoint, request)).rejects.toThrow(SHExpect.errorWithCode('not_found'));
220
+ await expect(testServer.test(endpoint, request)).rejects.toThrow(STExpect.errorWithCode('not_found'));
221
221
  });
222
222
 
223
223
  test('An admin can edit members registered in its own organization', async () => {
@@ -493,7 +493,7 @@ describe('Endpoint.PatchOrganizationMembersEndpoint', () => {
493
493
 
494
494
  const request = Request.buildJson('PATCH', baseUrl, organization.getApiHost(), arr);
495
495
  request.headers.authorization = 'Bearer ' + token.accessToken;
496
- await expect(testServer.test(endpoint, request)).rejects.toThrow(SHExpect.errorWithCode('permission_denied'));
496
+ await expect(testServer.test(endpoint, request)).rejects.toThrow(STExpect.errorWithCode('permission_denied'));
497
497
  });
498
498
 
499
499
  test('An admin without record category permission cannot set the records in that category', async () => {
@@ -562,7 +562,7 @@ describe('Endpoint.PatchOrganizationMembersEndpoint', () => {
562
562
 
563
563
  const request = Request.buildJson('PATCH', baseUrl, organization.getApiHost(), arr);
564
564
  request.headers.authorization = 'Bearer ' + token.accessToken;
565
- await expect(testServer.test(endpoint, request)).rejects.toThrow(SHExpect.errorWithCode('permission_denied'));
565
+ await expect(testServer.test(endpoint, request)).rejects.toThrow(STExpect.errorWithCode('permission_denied'));
566
566
  });
567
567
 
568
568
  test('An admin can set records of the platform', async () => {
@@ -2033,7 +2033,7 @@ describe('Endpoint.PatchOrganizationMembersEndpoint', () => {
2033
2033
  await member3.refresh();
2034
2034
 
2035
2035
  // Check all contacts equal
2036
- const expectedParent = contact2;
2036
+ const expectedParent = contact2.patch({ createdAt: contact1.createdAt }); // the oldest one is used
2037
2037
 
2038
2038
  expect(member1.details.emergencyContacts).toEqual([expectedParent]);
2039
2039
  expect(member2.details.emergencyContacts).toEqual([expectedParent]);
@@ -74,22 +74,6 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
74
74
 
75
75
  const platform = await Platform.getShared();
76
76
 
77
- // Cache
78
- const groups: Group[] = [];
79
-
80
- async function getGroup(id: string) {
81
- const f = groups.find(g => g.id === id);
82
- if (f) {
83
- return f;
84
- }
85
- const group = await Group.getByID(id);
86
- if (group) {
87
- groups.push(group);
88
- return group;
89
- }
90
- return null;
91
- }
92
-
93
77
  const updateMembershipMemberIds = new Set<string>();
94
78
  const updateMembershipsForOrganizations = new Set<string>();
95
79
 
@@ -0,0 +1,73 @@
1
+ import { SimpleError } from '@simonbackx/simple-errors';
2
+ import { Group } from '@stamhoofd/models';
3
+ import { FilterWrapperMarker, PermissionLevel, StamhoofdFilter, unwrapFilter, WrapperFilter } from '@stamhoofd/structures';
4
+ import { Context } from '../../../../helpers/Context';
5
+
6
+ export async function validateGroupFilter({ filter, permissionLevel, key }: { filter: StamhoofdFilter; permissionLevel: PermissionLevel; key: string | null }) {
7
+ // Require presence of a filter
8
+ const requiredFilter: WrapperFilter = key
9
+ ? {
10
+ [key]: {
11
+ $elemMatch: {
12
+ groupId: FilterWrapperMarker,
13
+ },
14
+ },
15
+ }
16
+ : {
17
+ groupId: FilterWrapperMarker,
18
+ };
19
+
20
+ const unwrapped = unwrapFilter(filter, requiredFilter);
21
+ if (!unwrapped.match) {
22
+ return false;
23
+ }
24
+
25
+ const groupIds = typeof unwrapped.markerValue === 'string'
26
+ ? [unwrapped.markerValue]
27
+ : unwrapFilter(unwrapped.markerValue as StamhoofdFilter, {
28
+ $in: FilterWrapperMarker,
29
+ })?.markerValue;
30
+
31
+ if (!Array.isArray(groupIds)) {
32
+ throw new SimpleError({
33
+ code: 'invalid_field',
34
+ field: 'filter',
35
+ message: 'You must filter on a group of the organization you are trying to access',
36
+ human: $t(`5efbaed8-004e-40b9-a822-bdb31e35fbb7`),
37
+ });
38
+ }
39
+
40
+ if (groupIds.length === 0) {
41
+ throw new SimpleError({
42
+ code: 'invalid_field',
43
+ field: 'filter',
44
+ message: 'Filtering on an empty list of groups is not supported',
45
+ });
46
+ }
47
+
48
+ for (const groupId of groupIds) {
49
+ if (typeof groupId !== 'string') {
50
+ throw new SimpleError({
51
+ code: 'invalid_field',
52
+ field: 'filter',
53
+ message: 'Invalid group ID in filter',
54
+ });
55
+ }
56
+ }
57
+
58
+ const groups = await Group.getByIDs(...groupIds as string[]);
59
+ Context.auth.cacheGroups(groups);
60
+
61
+ console.log('Fetching members for groups', groups.map(g => g.settings.name.toString()));
62
+
63
+ for (const group of groups) {
64
+ if (!await Context.auth.canAccessGroup(group, permissionLevel)) {
65
+ throw Context.auth.error({
66
+ message: 'You do not have access to this group',
67
+ human: $t(`45eedf49-0f0a-442c-a0bd-7881c2682698`, { groupName: group.settings.name }),
68
+ });
69
+ }
70
+ }
71
+
72
+ return true;
73
+ }
@@ -1,4 +1,3 @@
1
- import { ManyToOneRelation } from '@simonbackx/simple-database';
2
1
  import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
3
2
  import { SimpleError } from '@simonbackx/simple-errors';
4
3
  import { Group, Member, Payment, Registration } from '@stamhoofd/models';
@@ -61,7 +60,7 @@ export class GetPaymentRegistrations extends Endpoint<Params, Query, Body, Respo
61
60
  paidAt: payment.paidAt,
62
61
  createdAt: payment.createdAt,
63
62
  updatedAt: payment.updatedAt,
64
- registrations: registrations.map(r => Member.getRegistrationWithMemberStructure(r.setRelation(Registration.group, groups.find(g => g.id === r.groupId)!))),
63
+ registrations: registrations.map(r => Member.getRegistrationWithTinyMemberStructure(r.setRelation(Registration.group, groups.find(g => g.id === r.groupId)!))),
65
64
  }),
66
65
  );
67
66
  }
@@ -0,0 +1,43 @@
1
+ import { Decoder } from '@simonbackx/simple-encoding';
2
+ import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
3
+ import { CountFilteredRequest, CountResponse } from '@stamhoofd/structures';
4
+
5
+ import { Context } from '../../../helpers/Context';
6
+ import { GetRegistrationsEndpoint } from './GetRegistrationsEndpoint';
7
+
8
+ type Params = Record<string, never>;
9
+ type Query = CountFilteredRequest;
10
+ type Body = undefined;
11
+ type ResponseBody = CountResponse;
12
+
13
+ export class GetRegistrationsCountEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
14
+ queryDecoder = CountFilteredRequest as Decoder<CountFilteredRequest>;
15
+
16
+ protected doesMatch(request: Request): [true, Params] | [false] {
17
+ if (request.method !== 'GET') {
18
+ return [false];
19
+ }
20
+
21
+ const params = Endpoint.parseParameters(request.url, '/registrations/count', {});
22
+
23
+ if (params) {
24
+ return [true, params as Params];
25
+ }
26
+ return [false];
27
+ }
28
+
29
+ async handle(request: DecodedRequest<Params, Query, Body>) {
30
+ await Context.setOptionalOrganizationScope();
31
+ await Context.authenticate();
32
+ const query = await GetRegistrationsEndpoint.buildQuery(request.query);
33
+
34
+ const count = await query
35
+ .count();
36
+
37
+ return new Response(
38
+ CountResponse.create({
39
+ count,
40
+ }),
41
+ );
42
+ }
43
+ }