@stamhoofd/backend 2.101.0 → 2.102.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 (21) hide show
  1. package/package.json +10 -10
  2. package/src/crons.ts +1 -1
  3. package/src/email-recipient-loaders/receivable-balances.ts +18 -11
  4. package/src/endpoints/global/events/PatchEventsEndpoint.ts +1 -0
  5. package/src/endpoints/global/members/helpers/validateGroupFilter.ts +12 -8
  6. package/src/endpoints/global/registration/GetRegistrationsEndpoint.ts +30 -7
  7. package/src/endpoints/global/registration/GetUserPayableBalanceEndpoint.ts +4 -4
  8. package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +2 -2
  9. package/src/endpoints/organization/dashboard/billing/GetOrganizationPayableBalanceEndpoint.ts +2 -2
  10. package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalanceEndpoint.ts +60 -8
  11. package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalancesEndpoint.ts +6 -1
  12. package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.ts +12 -9
  13. package/src/excel-loaders/receivable-balances.ts +2 -2
  14. package/src/helpers/AdminPermissionChecker.ts +66 -8
  15. package/src/helpers/AuthenticatedStructures.ts +11 -6
  16. package/src/helpers/MemberUserSyncer.ts +2 -2
  17. package/src/seeds/{1735577912-update-cached-outstanding-balance-from-items.ts → 1760535589-update-cached-outstanding-balance-from-items.ts} +2 -0
  18. package/src/services/BalanceItemService.ts +12 -0
  19. package/src/sql-filters/base-registration-filter-compilers.ts +59 -0
  20. package/src/sql-filters/orders.ts +97 -1
  21. package/src/sql-filters/receivable-balances.ts +7 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stamhoofd/backend",
3
- "version": "2.101.0",
3
+ "version": "2.102.0",
4
4
  "main": "./dist/index.js",
5
5
  "exports": {
6
6
  ".": {
@@ -45,14 +45,14 @@
45
45
  "@simonbackx/simple-encoding": "2.22.0",
46
46
  "@simonbackx/simple-endpoints": "1.20.1",
47
47
  "@simonbackx/simple-logging": "^1.0.1",
48
- "@stamhoofd/backend-i18n": "2.101.0",
49
- "@stamhoofd/backend-middleware": "2.101.0",
50
- "@stamhoofd/email": "2.101.0",
51
- "@stamhoofd/models": "2.101.0",
52
- "@stamhoofd/queues": "2.101.0",
53
- "@stamhoofd/sql": "2.101.0",
54
- "@stamhoofd/structures": "2.101.0",
55
- "@stamhoofd/utility": "2.101.0",
48
+ "@stamhoofd/backend-i18n": "2.102.0",
49
+ "@stamhoofd/backend-middleware": "2.102.0",
50
+ "@stamhoofd/email": "2.102.0",
51
+ "@stamhoofd/models": "2.102.0",
52
+ "@stamhoofd/queues": "2.102.0",
53
+ "@stamhoofd/sql": "2.102.0",
54
+ "@stamhoofd/structures": "2.102.0",
55
+ "@stamhoofd/utility": "2.102.0",
56
56
  "archiver": "^7.0.1",
57
57
  "axios": "^1.8.2",
58
58
  "cookie": "^0.7.0",
@@ -70,5 +70,5 @@
70
70
  "publishConfig": {
71
71
  "access": "public"
72
72
  },
73
- "gitHead": "1eeeabc23e86e699d54f09fb385b5c32b4618723"
73
+ "gitHead": "0dcf9d704578c9f1972f7068b8727a5572c723c7"
74
74
  }
package/src/crons.ts CHANGED
@@ -124,7 +124,7 @@ async function checkWebshopDNS() {
124
124
  // Keep checking pending paymetns for 3 days
125
125
  async function checkPayments() {
126
126
  if (STAMHOOFD.environment === 'development') {
127
- return;
127
+ // return;
128
128
  }
129
129
 
130
130
  const timeout = 60 * 1000 * 31;
@@ -14,7 +14,7 @@ async function fetch(query: LimitedFilteredRequest, subfilter: StamhoofdFilter |
14
14
 
15
15
  const recipients: EmailRecipient[] = [];
16
16
  for (const balance of result.results) {
17
- const balanceItemModels = await CachedBalance.balanceForObjects(balance.organizationId, [balance.object.id], balance.objectType, true);
17
+ const balanceItemModels = balance.objectType === ReceivableBalanceType.organization ? (await CachedBalance.balanceForObjects(balance.organizationId, [balance.object.id], balance.objectType)) : [];
18
18
  const balanceItems = balanceItemModels.map(i => i.getStructure());
19
19
 
20
20
  const filteredContacts = balance.object.contacts.filter(c => compiledFilter(c));
@@ -22,7 +22,7 @@ async function fetch(query: LimitedFilteredRequest, subfilter: StamhoofdFilter |
22
22
  for (const email of contact.emails) {
23
23
  const recipient = EmailRecipient.create({
24
24
  objectId: balance.id, // Note: not set member, user or organization id here - should be the queryable balance id
25
- userId: balance.objectType === ReceivableBalanceType.user ? balance.object.id : null,
25
+ userId: balance.objectType === ReceivableBalanceType.user || balance.objectType === ReceivableBalanceType.userWithoutMembers ? balance.object.id : null,
26
26
  memberId: balance.objectType === ReceivableBalanceType.member ? balance.object.id : null,
27
27
  firstName: contact.firstName,
28
28
  lastName: contact.lastName,
@@ -32,15 +32,22 @@ async function fetch(query: LimitedFilteredRequest, subfilter: StamhoofdFilter |
32
32
  token: 'objectName',
33
33
  value: balance.object.name,
34
34
  }),
35
- Replacement.create({
36
- token: 'outstandingBalance',
37
- value: Formatter.price(balance.amountOpen),
38
- }),
39
- Replacement.create({
40
- token: 'balanceTable',
41
- value: '',
42
- html: BalanceItemStruct.getDetailsHTMLTable(balanceItems),
43
- }),
35
+ ...(
36
+ balance.objectType === ReceivableBalanceType.organization
37
+ ? [
38
+ Replacement.create({
39
+ token: 'outstandingBalance',
40
+ value: Formatter.price(balance.amountOpen),
41
+ }),
42
+ Replacement.create({
43
+ token: 'balanceTable',
44
+ value: '',
45
+ html: BalanceItemStruct.getDetailsHTMLTable(balanceItems),
46
+ }),
47
+ ]
48
+ : []
49
+ ),
50
+
44
51
  ...(contact.meta && contact.meta.url && typeof contact.meta.url === 'string'
45
52
  ? [Replacement.create({
46
53
  token: 'paymentUrl',
@@ -59,6 +59,7 @@ export class PatchEventsEndpoint extends Endpoint<Params, Query, Body, ResponseB
59
59
  putGroup,
60
60
  putGroup.organizationId,
61
61
  period,
62
+ { allowedIds: [putGroup.id] },
62
63
  );
63
64
  }
64
65
  else {
@@ -1,5 +1,4 @@
1
1
  import { SimpleError } from '@simonbackx/simple-errors';
2
- import { Group } from '@stamhoofd/models';
3
2
  import { FilterWrapperMarker, PermissionLevel, StamhoofdFilter, unwrapFilter, WrapperFilter } from '@stamhoofd/structures';
4
3
  import { Context } from '../../../../helpers/Context';
5
4
 
@@ -55,17 +54,22 @@ export async function validateGroupFilter({ filter, permissionLevel, key }: { fi
55
54
  }
56
55
  }
57
56
 
58
- const groups = await Group.getByIDs(...groupIds as string[]);
59
- Context.auth.cacheGroups(groups);
57
+ const groups = await Context.auth.getGroups(groupIds as string[]);
60
58
 
61
- console.log('Fetching members for groups', groups.map(g => g.settings.name.toString()));
59
+ console.log('Fetching members for groups before', groups.map(g => g.settings.name.toString()));
62
60
 
63
61
  for (const group of groups) {
64
62
  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
- });
63
+ if (permissionLevel !== PermissionLevel.Read || !group.settings.implicitlyAllowViewRegistrations) {
64
+ throw Context.auth.error({
65
+ message: 'You do not have access to this group',
66
+ human: $t(`45eedf49-0f0a-442c-a0bd-7881c2682698`, { groupName: group.settings.name }),
67
+ });
68
+ }
69
+ else {
70
+ // Return false so we add additional scope filters (only view overlap)
71
+ return false;
72
+ }
69
73
  }
70
74
  }
71
75
 
@@ -99,14 +99,26 @@ export class GetRegistrationsEndpoint extends Endpoint<Params, Query, Body, Resp
99
99
  if (await Context.auth.hasFullAccess(organization.id, permissionLevel)) {
100
100
  // Can access full history for now
101
101
  scopeFilter = {
102
- organizationId: organization.id,
102
+ member: {
103
+ registrations: {
104
+ $elemMatch: {
105
+ organizationId: organization.id,
106
+ },
107
+ },
108
+ },
103
109
  };
104
110
  }
105
111
  else {
106
112
  // Can only access current period
107
113
  scopeFilter = {
108
- organizationId: organization.id,
109
- periodId: organization.periodId,
114
+ member: {
115
+ registrations: {
116
+ $elemMatch: {
117
+ organizationId: organization.id,
118
+ periodId: organization.periodId,
119
+ },
120
+ },
121
+ },
110
122
  };
111
123
  }
112
124
  }
@@ -130,8 +142,14 @@ export class GetRegistrationsEndpoint extends Endpoint<Params, Query, Body, Resp
130
142
  }
131
143
 
132
144
  scopeFilter = {
133
- groupId: {
134
- $in: groupIds,
145
+ member: {
146
+ registrations: {
147
+ $elemMatch: {
148
+ groupId: {
149
+ $in: groupIds,
150
+ },
151
+ },
152
+ },
135
153
  },
136
154
  };
137
155
  }
@@ -196,8 +214,13 @@ export class GetRegistrationsEndpoint extends Endpoint<Params, Query, Body, Resp
196
214
  }
197
215
 
198
216
  for (const registration of data) {
199
- if (!await Context.auth.canAccessRegistration(registration, permissionLevel)) {
200
- throw Context.auth.error();
217
+ if (registration.group.settings.implicitlyAllowViewRegistrations) {
218
+ // okay, only need to check if we can access the members (next step)
219
+ }
220
+ else {
221
+ if (!await Context.auth.canAccessRegistration(registration, permissionLevel)) {
222
+ throw Context.auth.error();
223
+ }
201
224
  }
202
225
  }
203
226
 
@@ -1,6 +1,6 @@
1
1
  import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
2
2
  import { CachedBalance, Organization } from '@stamhoofd/models';
3
- import { PayableBalance, PayableBalanceCollection } from '@stamhoofd/structures';
3
+ import { PayableBalance, PayableBalanceCollection, ReceivableBalanceType } from '@stamhoofd/structures';
4
4
 
5
5
  import { Formatter } from '@stamhoofd/utility';
6
6
  import { AuthenticatedStructures } from '../../../helpers/AuthenticatedStructures';
@@ -31,12 +31,12 @@ export class GetUserPayableBalanceEndpoint extends Endpoint<Params, Query, Body,
31
31
  const organization = await Context.setUserOrganizationScope();
32
32
  const { user } = await Context.authenticate();
33
33
 
34
- return new Response(await GetUserPayableBalanceEndpoint.getBillingStatusForObjects([user.id], organization));
34
+ return new Response(await GetUserPayableBalanceEndpoint.getBillingStatusForObjects([user.id], organization, ReceivableBalanceType.user));
35
35
  }
36
36
 
37
- static async getBillingStatusForObjects(objectIds: string[], organization?: Organization | null) {
37
+ static async getBillingStatusForObjects(objectIds: string[], organization: Organization | null, objectType: ReceivableBalanceType) {
38
38
  // Load cached balances
39
- const receivableBalances = await CachedBalance.getForObjects(objectIds, organization?.id);
39
+ const receivableBalances = await CachedBalance.getForObjects(objectIds, organization?.id, objectType);
40
40
 
41
41
  const organizationIds = Formatter.uniqueArray(receivableBalances.map(b => b.organizationId));
42
42
 
@@ -5,7 +5,7 @@ import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-
5
5
  import { SimpleError } from '@simonbackx/simple-errors';
6
6
  import { Email } from '@stamhoofd/email';
7
7
  import { BalanceItem, BalanceItemPayment, CachedBalance, Group, Member, MemberWithRegistrations, MolliePayment, MollieToken, Organization, PayconiqPayment, Payment, Platform, RateLimiter, Registration, User } from '@stamhoofd/models';
8
- import { BalanceItemRelation, BalanceItemRelationType, BalanceItemStatus, BalanceItem as BalanceItemStruct, BalanceItemType, IDRegisterCheckout, PaymentCustomer, PaymentMethod, PaymentMethodHelper, PaymentProvider, PaymentStatus, Payment as PaymentStruct, PaymentType, PermissionLevel, PlatformFamily, PlatformMember, RegisterItem, RegisterResponse, TranslatedString, Version } from '@stamhoofd/structures';
8
+ import { BalanceItemRelation, BalanceItemRelationType, BalanceItemStatus, BalanceItem as BalanceItemStruct, BalanceItemType, IDRegisterCheckout, PaymentCustomer, PaymentMethod, PaymentMethodHelper, PaymentProvider, PaymentStatus, Payment as PaymentStruct, PaymentType, PermissionLevel, PlatformFamily, PlatformMember, ReceivableBalanceType, RegisterItem, RegisterResponse, TranslatedString, Version } from '@stamhoofd/structures';
9
9
  import { Formatter } from '@stamhoofd/utility';
10
10
 
11
11
  import { AuthenticatedStructures } from '../../../helpers/AuthenticatedStructures';
@@ -315,7 +315,7 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
315
315
  );
316
316
 
317
317
  // Never reuse a registration that has a balance - that means they had a cancellation fee and not all balance items were canceled (we don't want to merge in that state)
318
- const balances = await CachedBalance.getForObjects(possibleReuseRegistrations.map(r => r.id), null);
318
+ const balances = await CachedBalance.getForObjects(possibleReuseRegistrations.map(r => r.id), null, ReceivableBalanceType.registration);
319
319
 
320
320
  reuseRegistration = possibleReuseRegistrations.find((r) => {
321
321
  const balance = balances.filter(b => b.objectId === r.id).reduce((a, b) => a + b.amountOpen + b.amountPaid + b.amountPending, 0);
@@ -1,5 +1,5 @@
1
1
  import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
2
- import { PayableBalanceCollection } from '@stamhoofd/structures';
2
+ import { PayableBalanceCollection, ReceivableBalanceType } from '@stamhoofd/structures';
3
3
 
4
4
  import { Context } from '../../../../helpers/Context';
5
5
  import { GetUserPayableBalanceEndpoint } from '../../../global/registration/GetUserPayableBalanceEndpoint';
@@ -34,6 +34,6 @@ export class GetOrganizationPayableBalanceEndpoint extends Endpoint<Params, Quer
34
34
  throw Context.auth.error();
35
35
  }
36
36
 
37
- return new Response(await GetUserPayableBalanceEndpoint.getBillingStatusForObjects([organization.id], null));
37
+ return new Response(await GetUserPayableBalanceEndpoint.getBillingStatusForObjects([organization.id], null, ReceivableBalanceType.organization));
38
38
  }
39
39
  }
@@ -1,11 +1,12 @@
1
1
  import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
2
2
  import { DetailedReceivableBalance, PaymentStatus, ReceivableBalanceType } from '@stamhoofd/structures';
3
3
 
4
- import { BalanceItem, BalanceItemPayment, CachedBalance, Payment } from '@stamhoofd/models';
4
+ import { BalanceItem, BalanceItemPayment, CachedBalance, MemberUser, Payment } from '@stamhoofd/models';
5
5
  import { Context } from '../../../../helpers/Context';
6
6
  import { AuthenticatedStructures } from '../../../../helpers/AuthenticatedStructures';
7
7
  import { SQL } from '@stamhoofd/sql';
8
8
  import { BalanceItemService } from '../../../../services/BalanceItemService';
9
+ import { Formatter } from '@stamhoofd/utility';
9
10
 
10
11
  type Params = { id: string; type: ReceivableBalanceType };
11
12
  type Query = undefined;
@@ -38,14 +39,13 @@ export class GetReceivableBalanceEndpoint extends Endpoint<Params, Query, Body,
38
39
  throw Context.auth.error();
39
40
  }
40
41
 
41
- // Flush caches (this makes sure that we do a reload in the frontend after a registration or change, we get the newest balances)
42
- await BalanceItemService.flushCaches(organization.id);
43
-
44
- const balanceItemModels = await CachedBalance.balanceForObjects(organization.id, [request.params.id], request.params.type, true);
45
42
  let paymentModels: Payment[] = [];
46
43
 
47
44
  switch (request.params.type) {
48
45
  case ReceivableBalanceType.organization: {
46
+ // Force cache updates, because sometimes the cache could be out of date
47
+ BalanceItemService.scheduleOrganizationUpdate(organization.id, request.params.id);
48
+
49
49
  paymentModels = await Payment.select()
50
50
  .where('organizationId', organization.id)
51
51
  .andWhere(
@@ -66,6 +66,9 @@ export class GetReceivableBalanceEndpoint extends Endpoint<Params, Query, Body,
66
66
  }
67
67
 
68
68
  case ReceivableBalanceType.member: {
69
+ // Force cache updates, because sometimes the cache could be out of date
70
+ BalanceItemService.scheduleMemberUpdate(organization.id, request.params.id);
71
+
69
72
  paymentModels = await Payment.select()
70
73
  .where('organizationId', organization.id)
71
74
  .join(
@@ -86,7 +89,17 @@ export class GetReceivableBalanceEndpoint extends Endpoint<Params, Query, Body,
86
89
  }
87
90
 
88
91
  case ReceivableBalanceType.user: {
89
- paymentModels = await Payment.select()
92
+ const memberUsers = await MemberUser.select().where('usersId', request.params.id).fetch();
93
+ const memberIds = Formatter.uniqueArray(memberUsers.map(mu => mu.membersId));
94
+
95
+ // Force cache updates, because sometimes the cache could be out of date
96
+ BalanceItemService.scheduleUserUpdate(organization.id, request.params.id);
97
+
98
+ for (const memberId of memberIds) {
99
+ BalanceItemService.scheduleMemberUpdate(organization.id, memberId);
100
+ }
101
+
102
+ const q = Payment.select()
90
103
  .where('organizationId', organization.id)
91
104
  .join(
92
105
  SQL.join(BalanceItemPayment.table)
@@ -95,8 +108,44 @@ export class GetReceivableBalanceEndpoint extends Endpoint<Params, Query, Body,
95
108
  .join(
96
109
  SQL.join(BalanceItem.table)
97
110
  .where(SQL.column(BalanceItemPayment.table, 'balanceItemId'), SQL.column(BalanceItem.table, 'id')),
111
+ );
112
+
113
+ if (memberIds.length === 0) {
114
+ q.where(SQL.column(BalanceItem.table, 'userId'), request.params.id);
115
+ }
116
+ else {
117
+ q.where(
118
+ SQL.where(SQL.column(BalanceItem.table, 'userId'), request.params.id)
119
+ .or(SQL.column(BalanceItem.table, 'memberId'), memberIds),
120
+ );
121
+ }
122
+
123
+ paymentModels = await q
124
+ .andWhere(
125
+ SQL.whereNot('status', PaymentStatus.Failed),
98
126
  )
99
- .where(SQL.column(BalanceItem.table, 'userId'), request.params.id)
127
+ .groupBy(SQL.column(Payment.table, 'id'))
128
+ .fetch();
129
+ break;
130
+ }
131
+
132
+ case ReceivableBalanceType.userWithoutMembers: {
133
+ // Force cache updates, because sometimes the cache could be out of date
134
+ BalanceItemService.scheduleUserUpdate(organization.id, request.params.id);
135
+
136
+ const q = Payment.select()
137
+ .where('organizationId', organization.id)
138
+ .join(
139
+ SQL.join(BalanceItemPayment.table)
140
+ .where(SQL.column(BalanceItemPayment.table, 'paymentId'), SQL.column(Payment.table, 'id')),
141
+ )
142
+ .join(
143
+ SQL.join(BalanceItem.table)
144
+ .where(SQL.column(BalanceItemPayment.table, 'balanceItemId'), SQL.column(BalanceItem.table, 'id')),
145
+ )
146
+ .where(SQL.column(BalanceItem.table, 'userId'), request.params.id);
147
+
148
+ paymentModels = await q
100
149
  .andWhere(
101
150
  SQL.whereNot('status', PaymentStatus.Failed),
102
151
  )
@@ -126,10 +175,13 @@ export class GetReceivableBalanceEndpoint extends Endpoint<Params, Query, Body,
126
175
  }
127
176
  }
128
177
 
178
+ // Flush caches (this makes sure that we do a reload in the frontend after a registration or change, we get the newest balances)
179
+ await BalanceItemService.flushCaches(organization.id);
180
+ const balanceItemModels = await CachedBalance.balanceForObjects(organization.id, [request.params.id], request.params.type);
129
181
  const balanceItems = await BalanceItem.getStructureWithPayments(balanceItemModels);
130
182
  const payments = await AuthenticatedStructures.paymentsGeneral(paymentModels, false);
131
183
 
132
- const balances = await CachedBalance.getForObjects([request.params.id], organization.id);
184
+ const balances = await CachedBalance.getForObjects([request.params.id], organization.id, request.params.type);
133
185
 
134
186
  const created = new CachedBalance();
135
187
  created.amountOpen = 0;
@@ -83,7 +83,12 @@ export class GetReceivableBalancesEndpoint extends Endpoint<Params, Query, Body,
83
83
  },
84
84
  {
85
85
  users: {
86
- $elemMatch: { name: { $contains: q.search } },
86
+ $elemMatch: {
87
+ $or: {
88
+ name: { $contains: q.search },
89
+ email: { $contains: q.search },
90
+ },
91
+ },
87
92
  },
88
93
  },
89
94
  ],
@@ -37,7 +37,7 @@ export class PatchOrganizationRegistrationPeriodsEndpoint extends Endpoint<Param
37
37
  const organization = await Context.setOrganizationScope();
38
38
  await Context.authenticate();
39
39
 
40
- if (!await Context.auth.hasFullAccess(organization.id)) {
40
+ if (!await Context.auth.hasSomeAccess(organization.id)) {
41
41
  throw Context.auth.error();
42
42
  }
43
43
 
@@ -214,6 +214,14 @@ export class PatchOrganizationRegistrationPeriodsEndpoint extends Endpoint<Param
214
214
  for (const groupPut of patch.groups.getPuts()) {
215
215
  shouldUpdateSetupSteps = true;
216
216
  groupPut.put.settings.throwIfInvalidPrices();
217
+ if (groupPut.put.type === GroupType.EventRegistration) {
218
+ throw new SimpleError({
219
+ code: 'invalid_group_type',
220
+ message: 'Cannot create groups for events via this endpoint',
221
+ human: $t(`40dde58e-47fb-4adb-971a-537b16c479d5`),
222
+ });
223
+ }
224
+
217
225
  const group = await PatchOrganizationRegistrationPeriodsEndpoint.createGroup(groupPut.put, organization.id, period, { allowedIds });
218
226
  deleteUnreachable = true;
219
227
  forceGroupIds.push(group.id);
@@ -528,14 +536,9 @@ export class PatchOrganizationRegistrationPeriodsEndpoint extends Endpoint<Param
528
536
  static async createGroup(struct: GroupStruct, organizationId: string, period: RegistrationPeriod, options?: { allowedIds?: string[] }): Promise<Group> {
529
537
  const allowedIds = options?.allowedIds ?? [];
530
538
 
531
- if (struct.type === GroupType.Membership || struct.type === GroupType.WaitingList) {
539
+ if (struct.type !== GroupType.EventRegistration && !allowedIds.includes(struct.id)) {
532
540
  if (!await Context.auth.hasFullAccess(organizationId)) {
533
- if (allowedIds.includes(struct.id)) {
534
- // Ok
535
- }
536
- else {
537
- throw Context.auth.error($t(`153a7443-e2d9-4126-8e10-089b54964fb8`));
538
- }
541
+ throw Context.auth.error($t(`153a7443-e2d9-4126-8e10-089b54964fb8`));
539
542
  }
540
543
  }
541
544
  else {
@@ -587,7 +590,7 @@ export class PatchOrganizationRegistrationPeriodsEndpoint extends Endpoint<Param
587
590
  model.settings.registeredMembers = 0;
588
591
  model.settings.reservedMembers = 0;
589
592
 
590
- if (!await Context.auth.canAccessGroup(model, PermissionLevel.Full)) {
593
+ if (struct.type !== GroupType.EventRegistration && !await Context.auth.canAccessGroup(model, PermissionLevel.Full)) {
591
594
  // Create a temporary permission role for this user
592
595
  const organizationPermissions = user.permissions?.organizationPermissions?.get(organizationId);
593
596
  if (!organizationPermissions) {
@@ -1,5 +1,5 @@
1
1
  import { XlsxBuiltInNumberFormat, XlsxTransformerColumn, XlsxTransformerConcreteColumn } from '@stamhoofd/excel-writer';
2
- import { BalanceItemRelationType, BalanceItemWithPayments, DetailedReceivableBalance, ExcelExportType, getBalanceItemRelationTypeName, getBalanceItemStatusName, getBalanceItemTypeName, getReceivableBalanceTypeNameNotTranslated, PaginatedResponse, ReceivableBalance } from '@stamhoofd/structures';
2
+ import { BalanceItemRelationType, BalanceItemWithPayments, DetailedReceivableBalance, ExcelExportType, getBalanceItemRelationTypeName, getBalanceItemStatusName, getBalanceItemTypeName, getReceivableBalanceTypeName, PaginatedResponse, ReceivableBalance } from '@stamhoofd/structures';
3
3
  import { Formatter } from '@stamhoofd/utility';
4
4
  import { ExportToExcelEndpoint } from '../endpoints/global/files/ExportToExcelEndpoint';
5
5
  import { GetReceivableBalancesEndpoint } from '../endpoints/organization/dashboard/receivable-balances/GetReceivableBalancesEndpoint';
@@ -287,7 +287,7 @@ function getGeneralColumns(): XlsxTransformerConcreteColumn<ReceivableBalance>[]
287
287
  name: $t(`a0dfe596-0670-48bc-a5f3-2c9308c70a17`),
288
288
  width: 10,
289
289
  getValue: (object: ReceivableBalance) => ({
290
- value: getReceivableBalanceTypeNameNotTranslated(object.objectType),
290
+ value: getReceivableBalanceTypeName(object.objectType),
291
291
  }),
292
292
  },
293
293
  ];
@@ -1,7 +1,7 @@
1
1
  import { AutoEncoderPatchType, PatchMap } from '@simonbackx/simple-encoding';
2
2
  import { isSimpleError, isSimpleErrors, SimpleError } from '@simonbackx/simple-errors';
3
3
  import { BalanceItem, CachedBalance, Document, Email, EmailTemplate, Event, EventNotification, Group, Member, MemberPlatformMembership, MemberWithRegistrations, Order, Organization, OrganizationRegistrationPeriod, Payment, Registration, User, Webshop } from '@stamhoofd/models';
4
- import { AccessRight, EmailTemplate as EmailTemplateStruct, EventPermissionChecker, FinancialSupportSettings, GroupCategory, GroupStatus, GroupType, MemberWithRegistrationsBlob, PermissionLevel, PermissionsResourceType, Platform as PlatformStruct, RecordSettings, ResourcePermissions } from '@stamhoofd/structures';
4
+ import { AccessRight, EmailTemplate as EmailTemplateStruct, EventPermissionChecker, FinancialSupportSettings, GroupCategory, GroupStatus, GroupType, MemberWithRegistrationsBlob, PermissionLevel, PermissionsResourceType, Platform as PlatformStruct, ReceivableBalanceType, RecordSettings, ResourcePermissions } from '@stamhoofd/structures';
5
5
  import { Formatter } from '@stamhoofd/utility';
6
6
  import { MemberRecordStore } from '../services/MemberRecordStore';
7
7
  import { addTemporaryMemberAccess, hasTemporaryMemberAccess } from './TemporaryMemberAccess';
@@ -70,6 +70,7 @@ export class AdminPermissionChecker {
70
70
  return await cache;
71
71
  }
72
72
 
73
+ console.log('Get group', groupId);
73
74
  const promise = Group.select()
74
75
  .where('id', groupId)
75
76
  .first(false);
@@ -80,6 +81,46 @@ export class AdminPermissionChecker {
80
81
  return group;
81
82
  }
82
83
 
84
+ async getGroups(groupIds: string[]): Promise<Group[]> {
85
+ const cached: Group[] = [];
86
+ const remainingIds: string[] = [];
87
+
88
+ for (const groupId of groupIds) {
89
+ const cache = this.groupsCache.get(groupId);
90
+ if (cache !== undefined) {
91
+ const resolved = await cache;
92
+ if (resolved) {
93
+ cached.push(resolved);
94
+ }
95
+ else {
96
+ // Not found, no need to readd
97
+ }
98
+ }
99
+ else {
100
+ remainingIds.push(groupId);
101
+ }
102
+ }
103
+
104
+ if (remainingIds.length > 0) {
105
+ console.log('Get groups', remainingIds);
106
+ const promise = Group.select()
107
+ .where('id', remainingIds)
108
+ .fetch();
109
+
110
+ for (const groupId of remainingIds) {
111
+ this.groupsCache.set(groupId, promise.then(list => list.find(l => l.id === groupId) ?? null));
112
+ }
113
+ const groups = await promise;
114
+ cached.push(...groups);
115
+
116
+ for (const groupId of remainingIds) {
117
+ this.groupsCache.set(groupId, groups.find(l => l.id === groupId) ?? null);
118
+ }
119
+ }
120
+
121
+ return cached;
122
+ }
123
+
83
124
  cacheGroup(group: Group) {
84
125
  this.groupsCache.set(group.id, group);
85
126
  }
@@ -178,7 +219,7 @@ export class AdminPermissionChecker {
178
219
  async canAccessGroup(group: Group, permissionLevel: PermissionLevel = PermissionLevel.Read): Promise<boolean> {
179
220
  // Check permissions aren't scoped to a specific organization, and they mismatch
180
221
  if (!this.checkScope(group.organizationId)) {
181
- return false;
222
+ // return false;
182
223
  }
183
224
  const organization = await this.getOrganization(group.organizationId);
184
225
 
@@ -246,8 +287,13 @@ export class AdminPermissionChecker {
246
287
  return true;
247
288
  }
248
289
  if (asOrganizationId) {
249
- if (group.settings.allowRegistrationsByOrganization) {
250
- return await this.hasFullAccess(asOrganizationId);
290
+ if (group.settings.allowRegistrationsByOrganization && !group.getStructure().closed) {
291
+ if (group.organizationId !== asOrganizationId) {
292
+ return await this.hasFullAccess(asOrganizationId);
293
+ }
294
+ else {
295
+ return await this.hasSomeAccess(asOrganizationId);
296
+ }
251
297
  }
252
298
  }
253
299
  return false;
@@ -337,7 +383,7 @@ export class AdminPermissionChecker {
337
383
  }
338
384
 
339
385
  for (const registration of member.registrations) {
340
- if (await this.canAccessRegistration(registration, permissionLevel)) {
386
+ if (await this.canAccessRegistration(registration, permissionLevel, false)) {
341
387
  return true;
342
388
  }
343
389
  }
@@ -359,7 +405,7 @@ export class AdminPermissionChecker {
359
405
  */
360
406
  async canDeleteMember(member: MemberWithRegistrations) {
361
407
  if (member.registrations.length === 0 && this.isUserManager(member)) {
362
- const cachedBalance = await CachedBalance.getForObjects([member.id]);
408
+ const cachedBalance = await CachedBalance.getForObjects([member.id], null, ReceivableBalanceType.member);
363
409
  if (cachedBalance.length === 0 || (cachedBalance[0].amountOpen === 0 && cachedBalance[0].amountPending === 0)) {
364
410
  const platformMemberships = await MemberPlatformMembership.where({ memberId: member.id });
365
411
  if (platformMemberships.length === 0) {
@@ -378,7 +424,7 @@ export class AdminPermissionChecker {
378
424
  /**
379
425
  * Note: only checks admin permissions. Users that 'own' this member can also access it but that does not use the AdminPermissionChecker
380
426
  */
381
- async canAccessRegistration(registration: Registration, permissionLevel: PermissionLevel = PermissionLevel.Read) {
427
+ async canAccessRegistration(registration: Registration, permissionLevel: PermissionLevel = PermissionLevel.Read, checkMember = true) {
382
428
  if (registration.deactivatedAt || !registration.registeredAt) {
383
429
  // No full access: cannot access deactivated registrations
384
430
  return false;
@@ -404,7 +450,7 @@ export class AdminPermissionChecker {
404
450
  }
405
451
  }
406
452
 
407
- const group = await this.getGroup(registration.groupId);
453
+ const group = Registration.group.isLoaded(registration) ? ((registration as any).group as Group) : await this.getGroup(registration.groupId);
408
454
  if (!group || group.deletedAt) {
409
455
  return false;
410
456
  }
@@ -413,6 +459,17 @@ export class AdminPermissionChecker {
413
459
  return true;
414
460
  }
415
461
 
462
+ if (permissionLevel === PermissionLevel.Read && checkMember && group.settings.implicitlyAllowViewRegistrations) {
463
+ // We can also view this registration if we have access to the member
464
+ const members = await Member.getBlobByIds(registration.memberId);
465
+
466
+ if (members.length === 1) {
467
+ if (await this.canAccessMember(members[0], permissionLevel)) {
468
+ return true;
469
+ }
470
+ }
471
+ }
472
+
416
473
  return false;
417
474
  }
418
475
 
@@ -1298,6 +1355,7 @@ export class AdminPermissionChecker {
1298
1355
  }
1299
1356
 
1300
1357
  if (hasTemporaryMemberAccess(this.user.id, member.id, PermissionLevel.Full)) {
1358
+ // You created this member, so temporary can read all records in order to set the member up correctly
1301
1359
  return {
1302
1360
  canAccess: true,
1303
1361
  record: record.record,
@@ -399,10 +399,10 @@ export class AuthenticatedStructures {
399
399
  if (Context.organization) {
400
400
  await BalanceItemService.flushCaches(Context.organization.id);
401
401
  }
402
- const balances = await CachedBalance.getForObjects(registrationIds, null);
402
+ const balances = await CachedBalance.getForObjects(registrationIds, null, ReceivableBalanceType.registration);
403
403
  const memberIds = members.map(m => m.id);
404
404
  const allMemberBalances = Context.organization
405
- ? (await CachedBalance.getForObjects(memberIds, Context.organization.id))
405
+ ? (await CachedBalance.getForObjects(memberIds, Context.organization.id, ReceivableBalanceType.member))
406
406
  : [];
407
407
 
408
408
  if (includeUser) {
@@ -468,7 +468,12 @@ export class AuthenticatedStructures {
468
468
  }
469
469
  }
470
470
  if (organizations.get(registration.organizationId)?.active || (Context.auth.organization && Context.auth.organization.active && registration.organizationId === Context.auth.organization.id) || await Context.auth.hasFullAccess(registration.organizationId)) {
471
+ /* if ( // Causes issues with bundle discount calculations
472
+ registration.group.settings.implicitlyAllowViewRegistrations
473
+ || await Context.auth.canAccessRegistration(registration, PermissionLevel.Read)
474
+ ) { */
471
475
  filtered.push(registration);
476
+ // }
472
477
  }
473
478
  }
474
479
  member.registrations = filtered;
@@ -619,7 +624,7 @@ export class AuthenticatedStructures {
619
624
  const balancesPermission = member ? (await Context.auth.hasFinancialMemberAccess(member, PermissionLevel.Read, Context.organization?.id)) : false;
620
625
  r = registration.getStructure();
621
626
  r.balances = balancesPermission
622
- ? ((await CachedBalance.getForObjects([registration.id], null)).map((b) => {
627
+ ? ((await CachedBalance.getForObjects([registration.id], null, ReceivableBalanceType.registration)).map((b) => {
623
628
  return GenericBalance.create(b);
624
629
  }))
625
630
  : [];
@@ -863,7 +868,7 @@ export class AuthenticatedStructures {
863
868
  const members = memberIds.length > 0 ? await Member.getByIDs(...memberIds) : [];
864
869
 
865
870
  const userIds = Formatter.uniqueArray([
866
- ...balances.filter(b => b.objectType === ReceivableBalanceType.user).map(b => b.objectId),
871
+ ...balances.filter(b => b.objectType === ReceivableBalanceType.user || b.objectType === ReceivableBalanceType.userWithoutMembers).map(b => b.objectId),
867
872
  ]);
868
873
  const users = userIds.length > 0 ? await User.getByIDs(...userIds) : [];
869
874
 
@@ -988,7 +993,7 @@ export class AuthenticatedStructures {
988
993
  });
989
994
  }
990
995
  }
991
- else if (balance.objectType === ReceivableBalanceType.user) {
996
+ else if (balance.objectType === ReceivableBalanceType.user || balance.objectType === ReceivableBalanceType.userWithoutMembers) {
992
997
  const user = users.find(m => m.id === balance.objectId) ?? null;
993
998
  if (user) {
994
999
  const url = Context.organization && Context.organization.id === balance.organizationId ? 'https://' + Context.organization.getHost() : '';
@@ -1024,7 +1029,7 @@ export class AuthenticatedStructures {
1024
1029
  const results: DetailedReceivableBalance[] = [];
1025
1030
 
1026
1031
  for (const { balance, object } of items) {
1027
- const balanceItems = await CachedBalance.balanceForObjects(organizationId, [balance.objectId], balance.objectType, true);
1032
+ const balanceItems = await CachedBalance.balanceForObjects(organizationId, [balance.objectId], balance.objectType);
1028
1033
  const balanceItemsWithPayments = await BalanceItem.getStructureWithPayments(balanceItems);
1029
1034
 
1030
1035
  const result = DetailedReceivableBalance.create({
@@ -1,7 +1,7 @@
1
1
  import { CachedBalance, Member, MemberResponsibilityRecord, MemberWithUsers, Organization, Platform, User } from '@stamhoofd/models';
2
2
  import { QueueHandler } from '@stamhoofd/queues';
3
3
  import { SQL } from '@stamhoofd/sql';
4
- import { AuditLogSource, MemberDetails, PermissionRole, Permissions, UserPermissions } from '@stamhoofd/structures';
4
+ import { AuditLogSource, MemberDetails, PermissionRole, Permissions, ReceivableBalanceType, UserPermissions } from '@stamhoofd/structures';
5
5
  import { Formatter } from '@stamhoofd/utility';
6
6
  import basex from 'base-x';
7
7
  import crypto from 'crypto';
@@ -416,7 +416,7 @@ export class MemberUserSyncerStatic {
416
416
  */
417
417
  async updateUserBalance(userId: string, memberId: string) {
418
418
  // Update balance of this user, as it could have changed
419
- const memberBalances = await CachedBalance.getForObjects([memberId]);
419
+ const memberBalances = await CachedBalance.getForObjects([memberId], null, ReceivableBalanceType.member);
420
420
  if (memberBalances.length > 0) {
421
421
  const organizationIds = Formatter.uniqueArray(memberBalances.map(b => b.organizationId));
422
422
  for (const organizationId of organizationIds) {
@@ -21,6 +21,8 @@ export default new Migration(async () => {
21
21
  }
22
22
  });
23
23
 
24
+ await BalanceItemService.flushAll();
25
+
24
26
  console.log('Updated outstanding balance for ' + c + ' items');
25
27
 
26
28
  // Do something here
@@ -163,6 +163,18 @@ export const BalanceItemService = {
163
163
  }
164
164
  },
165
165
 
166
+ scheduleUserUpdate(organizationId: string, userId: string) {
167
+ userUpdateQueue.addItem(organizationId, userId);
168
+ },
169
+
170
+ scheduleMemberUpdate(organizationId: string, memberId: string) {
171
+ memberUpdateQueue.addItem(organizationId, memberId);
172
+ },
173
+
174
+ scheduleOrganizationUpdate(organizationId: string, payingOrganizationId: string) {
175
+ organizationUpdateQueue.addItem(organizationId, payingOrganizationId);
176
+ },
177
+
166
178
  async markDue(balanceItem: BalanceItem) {
167
179
  if (balanceItem.status === BalanceItemStatus.Hidden) {
168
180
  balanceItem.status = BalanceItemStatus.Due;
@@ -1,6 +1,59 @@
1
+ import { SimpleError } from '@simonbackx/simple-errors';
1
2
  import { baseSQLFilterCompilers, createColumnFilter, createExistsFilter, SQL, SQLFilterDefinitions, SQLValueType } from '@stamhoofd/sql';
3
+ import { FilterWrapperMarker, PermissionLevel, StamhoofdFilter, unwrapFilter } from '@stamhoofd/structures';
4
+ import { Context } from '../helpers/Context';
2
5
  import { organizationFilterCompilers } from './organizations';
3
6
 
7
+ async function checkGroupIdFilterAccess(filter: StamhoofdFilter, permissionLevel: PermissionLevel) {
8
+ const groupIds = typeof filter === 'string'
9
+ ? [filter]
10
+ : unwrapFilter(filter as StamhoofdFilter, {
11
+ $in: FilterWrapperMarker,
12
+ })?.markerValue;
13
+
14
+ if (!Array.isArray(groupIds)) {
15
+ throw new SimpleError({
16
+ code: 'invalid_field',
17
+ field: 'filter',
18
+ message: 'You must filter on a group of the organization you are trying to access',
19
+ human: $t(`d0ef2e12-dfa2-4d2a-9ee7-793e52e6b94f`),
20
+ });
21
+ }
22
+
23
+ if (groupIds.length === 0) {
24
+ return;
25
+ }
26
+
27
+ for (const groupId of groupIds) {
28
+ if (typeof groupId !== 'string') {
29
+ throw new SimpleError({
30
+ code: 'invalid_field',
31
+ field: 'filter',
32
+ message: 'Invalid group ID in filter',
33
+ });
34
+ }
35
+ }
36
+
37
+ const groups = await Context.auth.getGroups(groupIds as string[]);
38
+
39
+ console.log('Filtering registrations on groups', groups.map(g => g.settings.name.toString()));
40
+
41
+ for (const group of groups) {
42
+ if (!await Context.auth.canAccessGroup(group, permissionLevel)) {
43
+ if (permissionLevel === PermissionLevel.Read && group.settings.implicitlyAllowViewRegistrations) {
44
+ // Allowed to filter on this group, since we have view access.
45
+ continue;
46
+ }
47
+ throw Context.auth.error({
48
+ message: 'You do not have access to this group',
49
+ human: $t(`45eedf49-0f0a-442c-a0bd-7881c2682698`, { groupName: group.settings.name }),
50
+ });
51
+ }
52
+ }
53
+
54
+ return;
55
+ }
56
+
4
57
  export const baseRegistrationFilterCompilers: SQLFilterDefinitions = {
5
58
  ...baseSQLFilterCompilers,
6
59
  id: createColumnFilter({
@@ -38,6 +91,9 @@ export const baseRegistrationFilterCompilers: SQLFilterDefinitions = {
38
91
  expression: SQL.column('groupId'),
39
92
  type: SQLValueType.String,
40
93
  nullable: false,
94
+ async checkPermission(filter) {
95
+ await checkGroupIdFilterAccess(filter, PermissionLevel.Read);
96
+ },
41
97
  }),
42
98
  registeredAt: createColumnFilter({
43
99
  expression: SQL.column('registeredAt'),
@@ -60,6 +116,9 @@ export const baseRegistrationFilterCompilers: SQLFilterDefinitions = {
60
116
  expression: SQL.column('groupId'),
61
117
  type: SQLValueType.String,
62
118
  nullable: false,
119
+ async checkPermission(filter) {
120
+ await checkGroupIdFilterAccess(filter, PermissionLevel.Read);
121
+ },
63
122
  }),
64
123
  type: createColumnFilter({
65
124
  expression: SQL.column('groups', 'type'),
@@ -1,4 +1,4 @@
1
- import { baseSQLFilterCompilers, createColumnFilter, SQL, SQLCast, SQLConcat, SQLJsonUnquote, SQLFilterDefinitions, SQLValueType, SQLScalar } from '@stamhoofd/sql';
1
+ import { baseSQLFilterCompilers, createColumnFilter, SQL, SQLCast, SQLConcat, SQLJsonUnquote, SQLFilterDefinitions, SQLValueType, SQLScalar, createExistsFilter, createWildcardColumnFilter, SQLJsonExtract } from '@stamhoofd/sql';
2
2
 
3
3
  export const orderFilterCompilers: SQLFilterDefinitions = {
4
4
  ...baseSQLFilterCompilers,
@@ -106,4 +106,100 @@ export const orderFilterCompilers: SQLFilterDefinitions = {
106
106
  type: SQLValueType.Datetime,
107
107
  nullable: true,
108
108
  }),
109
+
110
+ items: createExistsFilter(
111
+ /**
112
+ * There is a bug in MySQL 8 that is fixed in 9.3
113
+ * where EXISTS (select * from json_table(...)) does not work
114
+ * To fix this, we do a double select with join inside the select
115
+ * It is a bit slower, but it works for now.
116
+ */
117
+ SQL.select()
118
+ .from('webshop_orders', 'innerOrders')
119
+ .join(
120
+ SQL.join(
121
+ SQL.jsonTable(
122
+ SQL.jsonValue(SQL.column('innerOrders', 'data'), '$.value.cart.items'),
123
+ 'items',
124
+ )
125
+ .addColumn(
126
+ 'amount',
127
+ 'INT',
128
+ '$.amount',
129
+ )
130
+ .addColumn(
131
+ 'productId',
132
+ 'TEXT',
133
+ '$.product.id',
134
+ )
135
+ .addColumn(
136
+ 'productPriceId',
137
+ 'TEXT',
138
+ '$.productPrice.id',
139
+ ),
140
+ ),
141
+ )
142
+ .where(SQL.column('innerOrders', 'id'), SQL.column('webshop_orders', 'id')),
143
+ {
144
+ ...baseSQLFilterCompilers,
145
+ amount: createColumnFilter({
146
+ expression: SQL.column('items', 'amount'),
147
+ type: SQLValueType.Number,
148
+ nullable: false,
149
+ }),
150
+ product: {
151
+ ...baseSQLFilterCompilers,
152
+ id: createColumnFilter({
153
+ expression: SQL.column('items', 'productId'),
154
+ type: SQLValueType.String,
155
+ nullable: false,
156
+ }),
157
+ },
158
+ productPrice: {
159
+ ...baseSQLFilterCompilers,
160
+ id: createColumnFilter({
161
+ expression: SQL.column('items', 'productPriceId'),
162
+ type: SQLValueType.String,
163
+ nullable: false,
164
+ }),
165
+ },
166
+ },
167
+ ),
168
+
169
+ recordAnswers: createWildcardColumnFilter(
170
+ (key: string) => ({
171
+ expression: SQL.jsonValue(SQL.column('data'), `$.value.recordAnswers.${SQLJsonExtract.escapePathComponent(key)}`, true),
172
+ type: SQLValueType.JSONObject,
173
+ nullable: true,
174
+ }),
175
+ (key: string) => ({
176
+ ...baseSQLFilterCompilers,
177
+ selected: createColumnFilter({
178
+ expression: SQL.jsonValue(SQL.column('data'), `$.value.recordAnswers.${SQLJsonExtract.escapePathComponent(key)}.selected`, true),
179
+ type: SQLValueType.JSONBoolean,
180
+ nullable: true,
181
+ }),
182
+ selectedChoice: {
183
+ ...baseSQLFilterCompilers,
184
+ id: createColumnFilter({
185
+ expression: SQL.jsonValue(SQL.column('data'), `$.value.recordAnswers.${SQLJsonExtract.escapePathComponent(key)}.selectedChoice.id`, true),
186
+ type: SQLValueType.JSONString,
187
+ nullable: true,
188
+ }),
189
+ },
190
+ selectedChoices: {
191
+ ...baseSQLFilterCompilers,
192
+ id: createColumnFilter({
193
+ expression: SQL.jsonValue(SQL.column('data'), `$.value.recordAnswers.${SQLJsonExtract.escapePathComponent(key)}.selectedChoices[*].id`, true),
194
+ type: SQLValueType.JSONArray,
195
+ nullable: true,
196
+ }),
197
+ },
198
+ value: createColumnFilter({
199
+ expression: SQL.jsonValue(SQL.column('data'), `$.value.recordAnswers.${SQLJsonExtract.escapePathComponent(key)}.value`, true),
200
+ type: SQLValueType.JSONString,
201
+ nullable: true,
202
+ }),
203
+ }),
204
+ ),
109
205
  };
@@ -97,7 +97,8 @@ export const receivableBalanceFilterCompilers: SQLFilterDefinitions = {
97
97
  SQL.column('cached_outstanding_balances', 'objectId'),
98
98
  ).where(
99
99
  SQL.column('cached_outstanding_balances', 'objectType'),
100
- 'user'),
100
+ ['user', 'userWithoutMembers'],
101
+ ),
101
102
  {
102
103
  ...baseSQLFilterCompilers,
103
104
  name: createColumnFilter({
@@ -109,6 +110,11 @@ export const receivableBalanceFilterCompilers: SQLFilterDefinitions = {
109
110
  type: SQLValueType.String,
110
111
  nullable: false,
111
112
  }),
113
+ email: createColumnFilter({
114
+ expression: SQL.column('email'),
115
+ type: SQLValueType.String,
116
+ nullable: false,
117
+ }),
112
118
  },
113
119
  ),
114
120