@stamhoofd/backend 2.79.7 → 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 +1 -0
- package/index.ts +1 -1
- package/package.json +10 -10
- package/src/endpoints/global/members/GetMembersEndpoint.ts +2 -2
- package/src/endpoints/global/organizations/SearchOrganizationEndpoint.test.ts +185 -1
- package/src/endpoints/global/organizations/SearchOrganizationEndpoint.ts +36 -18
- package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +9 -0
- package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.ts +9 -0
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.
|
|
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.
|
|
42
|
-
"@stamhoofd/backend-middleware": "2.79.
|
|
43
|
-
"@stamhoofd/email": "2.79.
|
|
44
|
-
"@stamhoofd/models": "2.79.
|
|
45
|
-
"@stamhoofd/queues": "2.79.
|
|
46
|
-
"@stamhoofd/sql": "2.79.
|
|
47
|
-
"@stamhoofd/structures": "2.79.
|
|
48
|
-
"@stamhoofd/utility": "2.79.
|
|
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": "
|
|
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,
|
|
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
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
}
|
|
@@ -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;
|