@stamhoofd/backend 2.124.0 → 2.124.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stamhoofd/backend",
3
- "version": "2.124.0",
3
+ "version": "2.124.1",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "exports": {
@@ -57,20 +57,20 @@
57
57
  "@simonbackx/simple-endpoints": "1.21.1",
58
58
  "@simonbackx/simple-errors": "1.5.0",
59
59
  "@simonbackx/simple-logging": "1.0.1",
60
- "@stamhoofd/backend-env": "2.124.0",
61
- "@stamhoofd/backend-i18n": "2.124.0",
62
- "@stamhoofd/backend-middleware": "2.124.0",
63
- "@stamhoofd/crons": "2.124.0",
64
- "@stamhoofd/email": "2.124.0",
65
- "@stamhoofd/excel-writer": "2.124.0",
66
- "@stamhoofd/logging": "2.124.0",
67
- "@stamhoofd/models": "2.124.0",
68
- "@stamhoofd/object-differ": "2.124.0",
69
- "@stamhoofd/queues": "2.124.0",
70
- "@stamhoofd/sql": "2.124.0",
71
- "@stamhoofd/structures": "2.124.0",
72
- "@stamhoofd/types": "2.124.0",
73
- "@stamhoofd/utility": "2.124.0",
60
+ "@stamhoofd/backend-env": "2.124.1",
61
+ "@stamhoofd/backend-i18n": "2.124.1",
62
+ "@stamhoofd/backend-middleware": "2.124.1",
63
+ "@stamhoofd/crons": "2.124.1",
64
+ "@stamhoofd/email": "2.124.1",
65
+ "@stamhoofd/excel-writer": "2.124.1",
66
+ "@stamhoofd/logging": "2.124.1",
67
+ "@stamhoofd/models": "2.124.1",
68
+ "@stamhoofd/object-differ": "2.124.1",
69
+ "@stamhoofd/queues": "2.124.1",
70
+ "@stamhoofd/sql": "2.124.1",
71
+ "@stamhoofd/structures": "2.124.1",
72
+ "@stamhoofd/types": "2.124.1",
73
+ "@stamhoofd/utility": "2.124.1",
74
74
  "archiver": "7.0.1",
75
75
  "axios": "1.16.0",
76
76
  "base-x": "3.0.11",
@@ -91,7 +91,7 @@
91
91
  "stripe": "16.12.0"
92
92
  },
93
93
  "devDependencies": {
94
- "@stamhoofd/test-utils": "2.124.0",
94
+ "@stamhoofd/test-utils": "2.124.1",
95
95
  "@types/cookie": "0.6.0",
96
96
  "@types/luxon": "3.7.1",
97
97
  "@types/mailparser": "3.4.6",
@@ -107,5 +107,5 @@
107
107
  "publishConfig": {
108
108
  "access": "public"
109
109
  },
110
- "gitHead": "7783583e6555a28cce7c7ad26cb223a4623ca005"
110
+ "gitHead": "d520495d0c1a5f376b10a60ebbceec63b2228bd5"
111
111
  }
@@ -1,7 +1,8 @@
1
1
  import { Request } from '@simonbackx/simple-endpoints';
2
2
  import type { Organization, Token } from '@stamhoofd/models';
3
- import { OrganizationFactory, UserFactory } from '@stamhoofd/models';
4
- import { LimitedFilteredRequest, PermissionLevel, Permissions } from '@stamhoofd/structures';
3
+ import { OrganizationFactory, STPackageFactory, UserFactory } from '@stamhoofd/models';
4
+ import { LimitedFilteredRequest, OrganizationMetaData, OrganizationType, PermissionLevel, Permissions, STPackageBundle, type StamhoofdFilter, UmbrellaOrganization } from '@stamhoofd/structures';
5
+ import { Country } from '@stamhoofd/types/Country';
5
6
  import { TestUtils } from '@stamhoofd/test-utils';
6
7
  import { testServer } from '../../../../tests/helpers/TestServer.js';
7
8
  import { initPlatformAdmin } from '../../../../tests/init/index.js';
@@ -37,6 +38,22 @@ describe('Endpoint.GetOrganizationsEndpoint', () => {
37
38
  return response.body.results.map(o => o.id);
38
39
  };
39
40
 
41
+ const filter = async (filter: StamhoofdFilter, token: Token): Promise<string[]> => {
42
+ const request = Request.get({
43
+ path: baseUrl,
44
+ host: '',
45
+ query: new LimitedFilteredRequest({
46
+ filter,
47
+ limit: 100,
48
+ }),
49
+ headers: {
50
+ authorization: 'Bearer ' + token.accessToken,
51
+ },
52
+ });
53
+ const response = await testServer.test(endpoint, request);
54
+ return response.body.results.map(o => o.id);
55
+ };
56
+
40
57
  // Creates an admin (user with permissions) for the given organization
41
58
  const createAdmin = async (organization: Organization, data: { firstName?: string; lastName?: string; email?: string }) => {
42
59
  return await new UserFactory({
@@ -104,4 +121,112 @@ describe('Endpoint.GetOrganizationsEndpoint', () => {
104
121
  expect(results).toContain(organization.id);
105
122
  expect(results).not.toContain(otherOrganization.id);
106
123
  });
124
+
125
+ test('Filters organizations by active package type', async () => {
126
+ const { adminToken } = await initPlatformAdmin();
127
+
128
+ const membersOrg = await new OrganizationFactory({ name: 'Packaged Members Club' }).create();
129
+ await new STPackageFactory({ organization: membersOrg, bundle: STPackageBundle.Members }).create();
130
+
131
+ const webshopsOrg = await new OrganizationFactory({ name: 'Packaged Webshops Club' }).create();
132
+ await new STPackageFactory({ organization: webshopsOrg, bundle: STPackageBundle.Webshops }).create();
133
+
134
+ const results = await filter({
135
+ packages: {
136
+ $elemMatch: {
137
+ type: {
138
+ $in: ['Members'],
139
+ },
140
+ },
141
+ },
142
+ }, adminToken);
143
+
144
+ expect(results).toContain(membersOrg.id);
145
+ expect(results).not.toContain(webshopsOrg.id);
146
+ });
147
+
148
+ test('Does not match organizations whose package is no longer valid', async () => {
149
+ const { adminToken } = await initPlatformAdmin();
150
+
151
+ const expiredOrg = await new OrganizationFactory({ name: 'Expired Members Club' }).create();
152
+ // removeAt in the past => package is no longer active
153
+ await new STPackageFactory({
154
+ organization: expiredOrg,
155
+ bundle: STPackageBundle.Members,
156
+ removeAt: new Date(Date.now() - 1000 * 60 * 60 * 24),
157
+ }).create();
158
+
159
+ const results = await filter({
160
+ packages: {
161
+ $elemMatch: {
162
+ type: {
163
+ $in: ['Members'],
164
+ },
165
+ },
166
+ },
167
+ }, adminToken);
168
+
169
+ expect(results).not.toContain(expiredOrg.id);
170
+ });
171
+
172
+ // Creates an organization with a specific type and/or umbrella organization
173
+ const createOrganizationWithMeta = async (name: string, metaPatch: { type?: OrganizationType; umbrellaOrganization?: UmbrellaOrganization | null }) => {
174
+ const meta = OrganizationMetaData.create({
175
+ type: metaPatch.type ?? OrganizationType.Other,
176
+ umbrellaOrganization: metaPatch.umbrellaOrganization ?? null,
177
+ defaultEndDate: new Date(),
178
+ defaultStartDate: new Date(),
179
+ defaultPrices: [],
180
+ });
181
+ return await new OrganizationFactory({ name, meta }).create();
182
+ };
183
+
184
+ test('Filters organizations by type', async () => {
185
+ const { adminToken } = await initPlatformAdmin();
186
+ const youthOrg = await createOrganizationWithMeta('Youthful Club', { type: OrganizationType.Youth });
187
+ const sportOrg = await createOrganizationWithMeta('Sporty Club', { type: OrganizationType.Sport });
188
+
189
+ const results = await filter({ type: { $in: [OrganizationType.Youth] } }, adminToken);
190
+ expect(results).toContain(youthOrg.id);
191
+ expect(results).not.toContain(sportOrg.id);
192
+ });
193
+
194
+ test('Filters organizations by umbrella organization', async () => {
195
+ const { adminToken } = await initPlatformAdmin();
196
+ const scoutsOrg = await createOrganizationWithMeta('Scouts Club', { umbrellaOrganization: UmbrellaOrganization.ScoutsEnGidsenVlaanderen });
197
+ const otherOrg = await createOrganizationWithMeta('No Umbrella Club', { umbrellaOrganization: null });
198
+
199
+ const results = await filter({ umbrellaOrganization: { $in: [UmbrellaOrganization.ScoutsEnGidsenVlaanderen] } }, adminToken);
200
+ expect(results).toContain(scoutsOrg.id);
201
+ expect(results).not.toContain(otherOrg.id);
202
+ });
203
+
204
+ test('Filters organizations by country', async () => {
205
+ const { adminToken } = await initPlatformAdmin();
206
+ // OrganizationFactory creates organizations in Belgium by default
207
+ const org = await new OrganizationFactory({ name: 'Belgian Club' }).create();
208
+
209
+ expect(await filter({ country: { $in: [Country.Belgium] } }, adminToken)).toContain(org.id);
210
+ expect(await filter({ country: { $in: [Country.Netherlands] } }, adminToken)).not.toContain(org.id);
211
+ });
212
+
213
+ test('Filters organizations by street', async () => {
214
+ const { adminToken } = await initPlatformAdmin();
215
+ // OrganizationFactory uses 'Demostraat' as the street
216
+ const org = await new OrganizationFactory({ name: 'Streeted Club' }).create();
217
+
218
+ expect(await filter({ street: { $contains: 'Demostraat' } }, adminToken)).toContain(org.id);
219
+ expect(await filter({ street: { $contains: 'Nonexistentstreet' } }, adminToken)).not.toContain(org.id);
220
+ });
221
+
222
+ test('Filters organizations by creation date', async () => {
223
+ const { adminToken } = await initPlatformAdmin();
224
+ const org = await new OrganizationFactory({ name: 'Freshly Created Club' }).create();
225
+
226
+ const anHourAgo = new Date(Date.now() - 1000 * 60 * 60);
227
+ const inAnHour = new Date(Date.now() + 1000 * 60 * 60);
228
+
229
+ expect(await filter({ createdAt: { $gt: anHourAgo } }, adminToken)).toContain(org.id);
230
+ expect(await filter({ createdAt: { $gt: inAnHour } }, adminToken)).not.toContain(org.id);
231
+ });
107
232
  });
@@ -125,7 +125,7 @@ export class GetOrganizationsEndpoint extends Endpoint<Params, Query, Body, Resp
125
125
  query.limit(q.limit);
126
126
  }
127
127
 
128
- console.log('GetOrganizationsEndpoint query', query.getSQL());
128
+ // console.log('GetOrganizationsEndpoint query', query.getSQL());
129
129
 
130
130
  return query;
131
131
  }
@@ -1,8 +1,8 @@
1
1
  import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
2
2
  import { Endpoint, Response } from '@simonbackx/simple-endpoints';
3
- import { DetailedReceivableBalance, PaymentStatus, ReceivableBalanceType } from '@stamhoofd/structures';
3
+ import { DetailedReceivableBalance, PaymentStatus, PermissionLevel, ReceivableBalanceType } from '@stamhoofd/structures';
4
4
 
5
- import { BalanceItem, BalanceItemPayment, CachedBalance, MemberUser, Payment } from '@stamhoofd/models';
5
+ import { BalanceItem, BalanceItemPayment, CachedBalance, Member, MemberUser, Payment, Registration } from '@stamhoofd/models';
6
6
  import { Context } from '../../../../helpers/Context.js';
7
7
  import { AuthenticatedStructures } from '../../../../helpers/AuthenticatedStructures.js';
8
8
  import { SQL } from '@stamhoofd/sql';
@@ -35,9 +35,23 @@ export class GetReceivableBalanceEndpoint extends Endpoint<Params, Query, Body,
35
35
  const organization = await Context.setOrganizationScope();
36
36
  await Context.authenticate();
37
37
 
38
- // If the user has permission, we'll also search if he has access to the organization's key
39
38
  if (!await Context.auth.canManageFinances(organization.id)) {
40
- throw Context.auth.error();
39
+ // Group-level financial access is sufficient for member and registration balance views
40
+ if (request.params.type === ReceivableBalanceType.member) {
41
+ const member = await Member.getByIdWithUsersAndRegistrations(request.params.id);
42
+ if (!member || !await Context.auth.hasFinancialMemberAccess(member, PermissionLevel.Read, organization.id)) {
43
+ throw Context.auth.error();
44
+ }
45
+ } else if (request.params.type === ReceivableBalanceType.registration) {
46
+ const registration = await Registration.select().where('id', request.params.id).first(false);
47
+ if (!registration) throw Context.auth.error();
48
+ const member = await Member.getByIdWithUsersAndRegistrations(registration.memberId);
49
+ if (!member || !await Context.auth.hasFinancialMemberAccess(member, PermissionLevel.Read, organization.id)) {
50
+ throw Context.auth.error();
51
+ }
52
+ } else {
53
+ throw Context.auth.error();
54
+ }
41
55
  }
42
56
 
43
57
  let paymentModels: Payment[] = [];
@@ -35,10 +35,6 @@ async function start(dryRun: boolean) {
35
35
  .where('periodId', organization.periodId)
36
36
  .fetch();
37
37
 
38
- if (groups.length === 0) {
39
- return;
40
- }
41
-
42
38
  // handle group permissions
43
39
  for (const group of groups) {
44
40
  const groupPermissions = group.privateSettings.permissions;
@@ -29,6 +29,16 @@ export const organizationFilterCompilers: SQLFilterDefinitions = {
29
29
  type: SQLValueType.Boolean,
30
30
  nullable: false,
31
31
  }),
32
+ createdAt: createColumnFilter({
33
+ expression: SQL.column('organizations', 'createdAt'),
34
+ type: SQLValueType.Datetime,
35
+ nullable: false,
36
+ }),
37
+ street: createColumnFilter({
38
+ expression: SQL.jsonExtract(SQL.column('organizations', 'address'), '$.value.street'),
39
+ type: SQLValueType.JSONString,
40
+ nullable: false,
41
+ }),
32
42
  city: createColumnFilter({
33
43
  expression: SQL.jsonExtract(SQL.column('organizations', 'address'), '$.value.city'),
34
44
  type: SQLValueType.JSONString,
@@ -88,4 +88,26 @@ export const organizationSorters: SQLSortDefinitions<Organization> = {
88
88
  });
89
89
  },
90
90
  },
91
+ umbrellaOrganization: {
92
+ getValue(a) {
93
+ return a.meta.umbrellaOrganization;
94
+ },
95
+ toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
96
+ return new SQLOrderBy({
97
+ column: SQL.jsonValue(SQL.column('meta'), '$.value.umbrellaOrganization', 'CHAR'),
98
+ direction,
99
+ });
100
+ },
101
+ },
102
+ createdAt: {
103
+ getValue(a) {
104
+ return a.createdAt;
105
+ },
106
+ toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
107
+ return new SQLOrderBy({
108
+ column: SQL.column('createdAt'),
109
+ direction,
110
+ });
111
+ },
112
+ },
91
113
  };