@stamhoofd/backend 2.79.6 → 2.79.8

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/.nvmrc ADDED
@@ -0,0 +1 @@
1
+ 20.12
package/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import backendEnv from '@stamhoofd/backend-env';
2
- backendEnv.load();
2
+ backendEnv.load({ service: 'api' });
3
3
 
4
4
  import { Column, Database, Migration } from '@simonbackx/simple-database';
5
5
  import { CORSPreflightEndpoint, Router, RouterServer } from '@simonbackx/simple-endpoints';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stamhoofd/backend",
3
- "version": "2.79.6",
3
+ "version": "2.79.8",
4
4
  "main": "./dist/index.js",
5
5
  "exports": {
6
6
  ".": {
@@ -38,14 +38,14 @@
38
38
  "@simonbackx/simple-encoding": "2.21.0",
39
39
  "@simonbackx/simple-endpoints": "1.19.1",
40
40
  "@simonbackx/simple-logging": "^1.0.1",
41
- "@stamhoofd/backend-i18n": "2.79.6",
42
- "@stamhoofd/backend-middleware": "2.79.6",
43
- "@stamhoofd/email": "2.79.6",
44
- "@stamhoofd/models": "2.79.6",
45
- "@stamhoofd/queues": "2.79.6",
46
- "@stamhoofd/sql": "2.79.6",
47
- "@stamhoofd/structures": "2.79.6",
48
- "@stamhoofd/utility": "2.79.6",
41
+ "@stamhoofd/backend-i18n": "2.79.8",
42
+ "@stamhoofd/backend-middleware": "2.79.8",
43
+ "@stamhoofd/email": "2.79.8",
44
+ "@stamhoofd/models": "2.79.8",
45
+ "@stamhoofd/queues": "2.79.8",
46
+ "@stamhoofd/sql": "2.79.8",
47
+ "@stamhoofd/structures": "2.79.8",
48
+ "@stamhoofd/utility": "2.79.8",
49
49
  "archiver": "^7.0.1",
50
50
  "aws-sdk": "^2.885.0",
51
51
  "axios": "1.6.8",
@@ -65,5 +65,5 @@
65
65
  "publishConfig": {
66
66
  "access": "public"
67
67
  },
68
- "gitHead": "5ed5677f28e62a73283527518b8e7ac0c8b4818e"
68
+ "gitHead": "06e4690b6413a21c3466d6ab76da3a883e1441c8"
69
69
  }
@@ -2,7 +2,7 @@ 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
4
  import { Member, Platform } from '@stamhoofd/models';
5
- import { SQL, compileToSQLFilter, applySQLSorter } from '@stamhoofd/sql';
5
+ import { SQL, applySQLSorter, compileToSQLFilter } from '@stamhoofd/sql';
6
6
  import { CountFilteredRequest, Country, CountryCode, LimitedFilteredRequest, MembersBlob, PaginatedResponse, PermissionLevel, StamhoofdFilter, assertSort, getSortFilter } from '@stamhoofd/structures';
7
7
  import { DataValidator } from '@stamhoofd/utility';
8
8
 
@@ -168,7 +168,7 @@ export class GetMembersEndpoint extends Endpoint<Params, Query, Body, ResponseBo
168
168
  }
169
169
 
170
170
  // Is lidnummer?
171
- if (!searchFilter && (q.search.match(/^[0-9]{4}-[0-9]{6}-[0-9]{1,2}$/) || q.search.match(/^[0-9]{10}$/))) {
171
+ if (!searchFilter && (q.search.match(/^[0-9]{4}-[0-9]{6}-[0-9]{1,2}$/) || q.search.match(/^[0-9]{9,10}$/))) {
172
172
  searchFilter = {
173
173
  memberNumber: {
174
174
  $eq: q.search,
@@ -1,5 +1,5 @@
1
1
  import { Request } from '@simonbackx/simple-endpoints';
2
- import { OrganizationFactory } from '@stamhoofd/models';
2
+ import { Organization, OrganizationFactory } from '@stamhoofd/models';
3
3
  import { v4 as uuidv4 } from 'uuid';
4
4
 
5
5
  import { testServer } from '../../../../tests/helpers/TestServer';
@@ -9,6 +9,10 @@ describe('Endpoint.SearchOrganization', () => {
9
9
  // Test endpoint
10
10
  const endpoint = new SearchOrganizationEndpoint();
11
11
 
12
+ afterEach(async () => {
13
+ await Organization.delete();
14
+ });
15
+
12
16
  test('Search for a given organization using exact search', async () => {
13
17
  const organization = await new OrganizationFactory({
14
18
  name: (uuidv4()).replace(/-/g, ''),
@@ -51,4 +55,184 @@ describe('Endpoint.SearchOrganization', () => {
51
55
  expect(response.status).toEqual(200);
52
56
  expect(response.body.map(o => o.id).sort()).toEqual(organizations.map(o => o.id).sort());
53
57
  });
58
+
59
+ test('Search organization by name using word should return best match first', async () => {
60
+ const name = 'WAT?';
61
+
62
+ for (let i = 0; i < 2; i++) {
63
+ await new OrganizationFactory({
64
+ name: 'Some other organization ' + (i + 1),
65
+ city: 'Waterloo',
66
+ }).create();
67
+ }
68
+
69
+ for (let i = 0; i < 2; i++) {
70
+ await new OrganizationFactory({
71
+ name: 'Some other organization 2 ' + (i + 1),
72
+ city: 'Wats',
73
+ }).create();
74
+ }
75
+
76
+ for (let i = 0; i < 2; i++) {
77
+ await new OrganizationFactory({
78
+ name: 'De Watten ' + (i + 1),
79
+ }).create();
80
+ }
81
+
82
+ // should appear first in results
83
+ const targetOrganization = await new OrganizationFactory({
84
+ name,
85
+ }).create();
86
+
87
+ for (let i = 0; i < 2; i++) {
88
+ await new OrganizationFactory({
89
+ name: 'De Watten 2 ' + (i + 1),
90
+ }).create();
91
+ }
92
+
93
+ const r = Request.buildJson('GET', '/v1/organizations/search');
94
+ r.query = {
95
+ query: name,
96
+ };
97
+
98
+ const response = await testServer.test(endpoint, r);
99
+ expect(response.body).toBeDefined();
100
+ expect(response.body).toHaveLength(9);
101
+ expect(response.body[0].id).toEqual(targetOrganization.id);
102
+ });
103
+
104
+ test('Search on organization by name using sentence should return best match first', async () => {
105
+ const query = 'Spaghetti Vreters';
106
+
107
+ for (const name of ['De Spaghetti Eters', 'Vreters', 'Spaghetti', 'De Spaghetti', 'De Spaghetti Vretersschool']) {
108
+ await new OrganizationFactory({
109
+ name,
110
+ }).create();
111
+ }
112
+
113
+ // should appear first in results
114
+ const targetOrganization = await new OrganizationFactory({
115
+ name: 'De Spaghetti Vreters',
116
+ }).create();
117
+
118
+ const r = Request.buildJson('GET', '/v1/organizations/search');
119
+ r.query = {
120
+ query,
121
+ };
122
+
123
+ const response = await testServer.test(endpoint, r);
124
+ expect(response.body).toBeDefined();
125
+ expect(response.body).toHaveLength(6);
126
+ expect(response.body[0].id).toEqual(targetOrganization.id);
127
+ });
128
+
129
+ test('Search on organization by name using word should return organization with searchindex that starts with query first if limit reached', async () => {
130
+ const query = 'Gent';
131
+
132
+ for (let i = 0; i < 10; i++) {
133
+ await new OrganizationFactory({
134
+ name: 'Some other Gent organization ' + (i + 1),
135
+ city: 'Gent',
136
+ }).create();
137
+ }
138
+
139
+ for (let i = 0; i < 5; i++) {
140
+ await new OrganizationFactory({
141
+ name: 'De Gentenaars ' + (i + 1),
142
+ }).create();
143
+ }
144
+
145
+ // should appear first in results
146
+ const targetOrganization = await new OrganizationFactory({
147
+ name: 'Gent',
148
+ }).create();
149
+
150
+ for (let i = 0; i < 3; i++) {
151
+ await new OrganizationFactory({
152
+ name: 'De Gentenaars 2 ' + (i + 1),
153
+ }).create();
154
+ }
155
+
156
+ for (let i = 0; i < 10; i++) {
157
+ await new OrganizationFactory({
158
+ name: 'Some other organization 2 ' + (i + 1),
159
+ city: 'Gent',
160
+ }).create();
161
+ }
162
+
163
+ const r = Request.buildJson('GET', '/v1/organizations/search');
164
+ r.query = {
165
+ query,
166
+ };
167
+
168
+ const response = await testServer.test(endpoint, r);
169
+ expect(response.body).toBeDefined();
170
+ expect(response.body).toHaveLength(15);
171
+ expect(response.body[0].name).toEqual(targetOrganization.name);
172
+ expect(response.body[0].id).toEqual(targetOrganization.id);
173
+ });
174
+
175
+ test('Search on organization by name using sentence should return organization with name that starts with query first if limit reached', async () => {
176
+ const query = 'De Spaghetti Vreters';
177
+
178
+ // without the where like (if the limit is reached), 'Spaghetti Vreters Spaghetti Vreters' would come first ('De' is a stopword)
179
+ for (const name of ['De Spaghetti Eters', 'Vreters', 'Spaghetti', 'De Spaghetti', 'Spaghetti Vreters', 'Spaghetti Vreters Spaghetti Vreters', 'De Spaghetti Vretersschool', 'Spaghetti 2', 'Spaghetti 3', 'Spaghetti 4', 'Spaghetti 5', 'Spaghetti 6', 'Spaghetti 7', 'Spaghetti 8', 'Spaghetti 9', 'Spaghetti 10']) {
180
+ await new OrganizationFactory({
181
+ name,
182
+ }).create();
183
+ }
184
+
185
+ let i = 1;
186
+ for (const city of ['De Spaghetti Eters', 'Vreters', 'Spaghetti', 'De Spaghetti', 'De Spaghetti Vretersschool']) {
187
+ await new OrganizationFactory({
188
+ name: 'name ' + i,
189
+ city,
190
+ }).create();
191
+ i = i + 1;
192
+ }
193
+
194
+ // should appear first in results
195
+ const targetOrganization = await new OrganizationFactory({
196
+ name: 'De Spaghetti Vreters',
197
+ }).create();
198
+
199
+ const r = Request.buildJson('GET', '/v1/organizations/search');
200
+ r.query = {
201
+ query,
202
+ };
203
+
204
+ const response = await testServer.test(endpoint, r);
205
+ expect(response.body).toBeDefined();
206
+ expect(response.body).toHaveLength(15);
207
+ expect(response.body[0].name).toEqual(targetOrganization.name);
208
+ expect(response.body[0].id).toEqual(targetOrganization.id);
209
+ });
210
+
211
+ test('Search on organization by name and city should return organization that matches city and name first', async () => {
212
+ const query = 'De Spaghetti Vreters Gent';
213
+
214
+ for (let i = 0; i < 5; i++) {
215
+ await new OrganizationFactory({
216
+ name: 'De Spaghetti Vreters ' + (i + 1),
217
+ city: 'Wetteren',
218
+ }).create();
219
+ }
220
+
221
+ // should appear first in results
222
+ const targetOrganization = await new OrganizationFactory({
223
+ name: 'De Spaghetti Vreters 16',
224
+ city: 'Gent',
225
+ }).create();
226
+
227
+ const r = Request.buildJson('GET', '/v1/organizations/search');
228
+ r.query = {
229
+ query,
230
+ };
231
+
232
+ const response = await testServer.test(endpoint, r);
233
+ expect(response.body).toBeDefined();
234
+ expect(response.body).toHaveLength(6);
235
+ expect(response.body[0].name).toEqual(targetOrganization.name);
236
+ expect(response.body[0].id).toEqual(targetOrganization.id);
237
+ });
54
238
  });
@@ -1,6 +1,7 @@
1
1
  import { AutoEncoder, Decoder, field, StringDecoder } from '@simonbackx/simple-encoding';
2
2
  import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
3
3
  import { Organization } from '@stamhoofd/models';
4
+ import { scalarToSQLExpression, SQL, SQLMatch, SQLWhere, SQLWhereLike } from '@stamhoofd/sql';
4
5
  import { Organization as OrganizationStruct } from '@stamhoofd/structures';
5
6
  import { AuthenticatedStructures } from '../../../helpers/AuthenticatedStructures';
6
7
 
@@ -32,28 +33,45 @@ export class SearchOrganizationEndpoint extends Endpoint<Params, Query, Body, Re
32
33
 
33
34
  async handle(request: DecodedRequest<Params, Query, Body>) {
34
35
  // Escape query
35
- const query = request.query.query.replace(/([-+><()~*"@\s]+)/g, ' ').replace(/[^\w\d]+$/, '');
36
- if (query.length == 0) {
36
+ const query = request.query.query.replace(/([-+><()~*"@\s]+)/g, ' ').replace(/[^\w\d]+$/, '').trim();
37
+ if (query.length === 0) {
37
38
  // Do not try searching...
38
39
  return new Response([]);
39
40
  }
40
41
 
41
- const match = {
42
- sign: 'MATCH',
43
- value: query + '*', // We replace special operators in boolean mode with spaces since special characters aren't indexed anyway
44
- mode: 'BOOLEAN',
45
- };
46
-
47
- // We had to add an order by in the query to fix the limit. MySQL doesn't want to limit the results correctly if we don't explicitly sort the results on their relevance
48
- const organizations = await Organization.where({ searchIndex: match, active: 1 }, {
49
- limit: 15,
50
- sort: [
51
- {
52
- column: { searchIndex: match },
53
- direction: 'DESC',
54
- },
55
- ],
56
- });
42
+ let matchValue: string;
43
+
44
+ if (query.includes(' ')) {
45
+ // give higher relevance if the searchindex includes the exact sentence
46
+ // give lower relevance if the last word is not a complete match
47
+ matchValue = `>("${query}") (${query}) <(${query}*)`;
48
+ }
49
+ else {
50
+ // give higher relevance if the searchindex includes the exact word
51
+ matchValue = `>${query} ${query}*`;
52
+ }
53
+
54
+ const limit = 15;
55
+
56
+ const whereMatch: SQLWhere = new SQLMatch(SQL.column(Organization.table, 'searchIndex'), scalarToSQLExpression(matchValue));
57
+
58
+ let organizations = await Organization.select()
59
+ .where(whereMatch)
60
+ .orderBy(whereMatch, 'DESC')
61
+ .limit(limit).fetch();
62
+
63
+ // if the limit is reached it is possible that organizations where the name starts with the query are missing -> fetch them and add them at the start
64
+ if (organizations.length === limit) {
65
+ const organizationsStartingWith = await Organization.select()
66
+ .where(new SQLWhereLike(SQL.column(Organization.table, 'name'), scalarToSQLExpression(`${query}%`)))
67
+ // order by relevance
68
+ .orderBy(whereMatch, 'DESC')
69
+ .limit(limit).fetch();
70
+
71
+ const organizationsStartingWithIds = new Set(organizationsStartingWith.map(o => o.id));
72
+
73
+ organizations = organizationsStartingWith.concat(organizations.filter(o => !organizationsStartingWithIds.has(o.id)));
74
+ }
57
75
 
58
76
  return new Response(await Promise.all(organizations.map(o => AuthenticatedStructures.organization(o))));
59
77
  }
@@ -457,41 +457,9 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
457
457
  balanceItem.organizationId = organization.id;
458
458
 
459
459
  // Who is responsible for payment?
460
- let balanceItem2: BalanceItem | null = null;
461
460
  if (registration.payingOrganizationId) {
462
- // Create a separate balance item for this meber to pay back the paying organization
463
- // this is not yet associated with a payment but will be added to the outstanding balance of the member
464
-
461
+ // We no longer also charge the member. This has been removed, ref STA-288
465
462
  balanceItem.payingOrganizationId = registration.payingOrganizationId;
466
-
467
- balanceItem2 = new BalanceItem();
468
-
469
- // NOTE: we don't connect the registrationId here
470
- // because otherwise the total price and pricePaid for the registration would be incorrect
471
- // balanceItem2.registrationId = registration.id;
472
-
473
- balanceItem2.unitPrice = unitPrice;
474
- balanceItem2.amount = amount ?? 1;
475
- balanceItem2.description = description;
476
- balanceItem2.relations = relations;
477
- balanceItem2.type = type;
478
-
479
- // Who needs to receive this money?
480
- balanceItem2.organizationId = registration.payingOrganizationId;
481
-
482
- // Who is responsible for payment?
483
- balanceItem2.memberId = registration.memberId;
484
-
485
- if (registration.trialUntil) {
486
- balanceItem2.dueAt = registration.trialUntil;
487
- }
488
-
489
- // If the paying organization hasn't paid yet, this should be hidden and move to pending as soon as the paying organization has paid
490
- balanceItem2.status = BalanceItemStatus.Hidden;
491
- await balanceItem2.save();
492
-
493
- // do not add to createdBalanceItems array because we don't want to add this to the payment if we create a payment
494
- unrelatedCreatedBalanceItems.push(balanceItem2);
495
463
  }
496
464
  else {
497
465
  balanceItem.memberId = registration.memberId;
@@ -501,9 +469,6 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
501
469
  balanceItem.status = BalanceItemStatus.Hidden;
502
470
  balanceItem.pricePaid = 0;
503
471
 
504
- // Connect the 'pay back' balance item to this balance item. As soon as this balance item is paid, we'll mark the other one as pending so the outstanding balance for the member increases
505
- balanceItem.dependingBalanceItemId = balanceItem2?.id ?? null;
506
-
507
472
  if (registration.trialUntil) {
508
473
  balanceItem.dueAt = registration.trialUntil;
509
474
  }
@@ -352,6 +352,15 @@ export class PatchOrganizationEndpoint extends Endpoint<Params, Query, Body, Res
352
352
  });
353
353
  }
354
354
 
355
+ const maximumStart = 1000 * 60 * 60 * 24 * 31 * 2; // 2 months in advance
356
+ if (period.startDate > new Date(Date.now() + maximumStart)) {
357
+ throw new SimpleError({
358
+ code: 'invalid_field',
359
+ message: 'Het werkjaar die je wilt instellen is nog niet gestart',
360
+ field: 'period',
361
+ });
362
+ }
363
+
355
364
  organization.periodId = period.id;
356
365
  shouldUpdateSetupSteps = true;
357
366
  }
@@ -280,6 +280,15 @@ export class PatchOrganizationRegistrationPeriodsEndpoint extends Endpoint<Param
280
280
  });
281
281
  }
282
282
 
283
+ const maximumStart = 1000 * 60 * 60 * 24 * 31 * 2; // 2 months in advance
284
+ if (period.startDate > new Date(Date.now() + maximumStart)) {
285
+ throw new SimpleError({
286
+ code: 'invalid_field',
287
+ message: 'Het werkjaar die je wilt instellen is nog niet gestart',
288
+ field: 'period',
289
+ });
290
+ }
291
+
283
292
  const organizationPeriod = new OrganizationRegistrationPeriod();
284
293
  organizationPeriod.id = struct.id;
285
294
  organizationPeriod.organizationId = organization.id;
@@ -391,7 +391,7 @@ export class AuthenticatedStructures {
391
391
  const organizations = new Map<string, Organization>();
392
392
 
393
393
  const registrationIds = Formatter.uniqueArray(members.flatMap(m => m.registrations.map(r => r.id)));
394
- const balances = await CachedBalance.getForObjects(registrationIds, Context.organization?.id ?? null);
394
+ const balances = await CachedBalance.getForObjects(registrationIds, null);
395
395
 
396
396
  if (includeUser) {
397
397
  for (const organizationId of includeUser.permissions?.organizationPermissions.keys() ?? []) {