@stamhoofd/backend 2.79.7 → 2.80.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.
- package/.nvmrc +1 -0
- package/index.ts +1 -1
- package/package.json +11 -11
- package/src/audit-logs/ModelLogger.ts +2 -2
- package/src/crons/amazon-ses.ts +215 -227
- package/src/crons/clearExcelCache.test.ts +1 -1
- package/src/endpoints/admin/members/ChargeMembersEndpoint.ts +105 -0
- package/src/endpoints/admin/organizations/ChargeOrganizationsEndpoint.ts +6 -10
- package/src/endpoints/global/events/PatchEventNotificationsEndpoint.test.ts +997 -0
- package/src/endpoints/global/events/PatchEventNotificationsEndpoint.ts +19 -3
- package/src/endpoints/global/members/GetMembersEndpoint.ts +9 -9
- 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/organization/SetOrganizationDomainEndpoint.ts +10 -3
- package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.ts +9 -0
- package/src/helpers/AdminPermissionChecker.ts +3 -3
- package/src/helpers/ForwardHandler.ts +3 -2
- package/src/helpers/MemberCharger.ts +39 -0
- package/src/helpers/OrganizationCharger.ts +9 -20
- package/src/services/EventNotificationService.ts +3 -0
- package/tests/e2e/charge-members.test.ts +429 -0
- package/tests/jest.setup.ts +7 -1
- package/tests/toMatchMap.ts +68 -0
- package/src/services/diff.ts +0 -514
|
@@ -81,7 +81,7 @@ export class PatchEventNotificationsEndpoint extends Endpoint<Params, Query, Bod
|
|
|
81
81
|
if (index === 0) {
|
|
82
82
|
notification.startDate = model.startDate;
|
|
83
83
|
notification.endDate = model.endDate;
|
|
84
|
-
const period = await RegistrationPeriod.getByDate(
|
|
84
|
+
const period = await RegistrationPeriod.getByDate(model.startDate);
|
|
85
85
|
|
|
86
86
|
if (!period) {
|
|
87
87
|
throw new SimpleError({
|
|
@@ -141,7 +141,7 @@ export class PatchEventNotificationsEndpoint extends Endpoint<Params, Query, Bod
|
|
|
141
141
|
if (
|
|
142
142
|
notification.status === EventNotificationStatus.Pending
|
|
143
143
|
|| notification.status === EventNotificationStatus.Accepted
|
|
144
|
-
|| (patch.status && patch.status !== EventNotificationStatus.Pending)
|
|
144
|
+
|| (patch.status && (patch.status !== EventNotificationStatus.Pending || (notification.status !== EventNotificationStatus.Draft && notification.status !== EventNotificationStatus.Rejected && notification.status !== EventNotificationStatus.PartiallyAccepted)))
|
|
145
145
|
|| patch.feedbackText !== undefined
|
|
146
146
|
) {
|
|
147
147
|
requiredPermissionLevel = PermissionLevel.Full;
|
|
@@ -187,6 +187,7 @@ export class PatchEventNotificationsEndpoint extends Endpoint<Params, Query, Bod
|
|
|
187
187
|
// Only allowed if complete
|
|
188
188
|
await this.validateAnswers(notification);
|
|
189
189
|
}
|
|
190
|
+
const previousStatus = notification.status;
|
|
190
191
|
notification.status = patch.status; // checks already happened
|
|
191
192
|
if (patch.status === EventNotificationStatus.Pending) {
|
|
192
193
|
notification.submittedBy = user.id;
|
|
@@ -198,15 +199,30 @@ export class PatchEventNotificationsEndpoint extends Endpoint<Params, Query, Bod
|
|
|
198
199
|
await EventNotificationService.sendReviewerEmail(EmailTemplateType.EventNotificationSubmittedReviewer, notification);
|
|
199
200
|
}
|
|
200
201
|
|
|
201
|
-
if (patch.status === EventNotificationStatus.Accepted) {
|
|
202
|
+
if ((patch.status === EventNotificationStatus.Accepted) && previousStatus !== EventNotificationStatus.Accepted) {
|
|
203
|
+
// Make sure the accepted record answers stay in sync
|
|
204
|
+
notification.acceptedRecordAnswers = notification.recordAnswers;
|
|
205
|
+
|
|
202
206
|
await EventNotificationService.sendSubmitterEmail(EmailTemplateType.EventNotificationAccepted, notification);
|
|
203
207
|
}
|
|
204
208
|
|
|
209
|
+
if ((patch.status === EventNotificationStatus.PartiallyAccepted) && previousStatus !== EventNotificationStatus.Accepted && previousStatus !== EventNotificationStatus.PartiallyAccepted) {
|
|
210
|
+
// Make sure the accepted record answers stay in sync
|
|
211
|
+
notification.acceptedRecordAnswers = notification.recordAnswers;
|
|
212
|
+
|
|
213
|
+
await EventNotificationService.sendSubmitterEmail(EmailTemplateType.EventNotificationPartiallyAccepted, notification);
|
|
214
|
+
}
|
|
215
|
+
|
|
205
216
|
if (patch.status === EventNotificationStatus.Rejected) {
|
|
206
217
|
await EventNotificationService.sendSubmitterEmail(EmailTemplateType.EventNotificationRejected, notification);
|
|
207
218
|
}
|
|
208
219
|
}
|
|
209
220
|
|
|
221
|
+
if (notification.status === EventNotificationStatus.Accepted) {
|
|
222
|
+
// Make sure the accepted record answers stay in sync (only for full accepted, since these cannot be changed)
|
|
223
|
+
notification.acceptedRecordAnswers = notification.recordAnswers;
|
|
224
|
+
}
|
|
225
|
+
|
|
210
226
|
await notification.save();
|
|
211
227
|
notifications.push(notification);
|
|
212
228
|
}
|
|
@@ -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
|
|
|
@@ -37,12 +37,12 @@ export class GetMembersEndpoint extends Endpoint<Params, Query, Body, ResponseBo
|
|
|
37
37
|
return [false];
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
static async buildQuery(q: CountFilteredRequest | LimitedFilteredRequest) {
|
|
40
|
+
static async buildQuery(q: CountFilteredRequest | LimitedFilteredRequest, permissionLevel: PermissionLevel = PermissionLevel.Read) {
|
|
41
41
|
const organization = Context.organization;
|
|
42
42
|
let scopeFilter: StamhoofdFilter | undefined = undefined;
|
|
43
43
|
|
|
44
44
|
if (!organization && !Context.auth.canAccessAllPlatformMembers()) {
|
|
45
|
-
const tags = Context.auth.getPlatformAccessibleOrganizationTags(
|
|
45
|
+
const tags = Context.auth.getPlatformAccessibleOrganizationTags(permissionLevel);
|
|
46
46
|
if (tags !== 'all' && tags.length === 0) {
|
|
47
47
|
throw Context.auth.error();
|
|
48
48
|
}
|
|
@@ -68,14 +68,14 @@ export class GetMembersEndpoint extends Endpoint<Params, Query, Body, ResponseBo
|
|
|
68
68
|
|
|
69
69
|
if (organization && !Context.auth.canAccessAllPlatformMembers()) {
|
|
70
70
|
// Add organization scope filter
|
|
71
|
-
const groups = await Context.auth.getAccessibleGroups(organization.id);
|
|
71
|
+
const groups = await Context.auth.getAccessibleGroups(organization.id, permissionLevel);
|
|
72
72
|
|
|
73
73
|
if (groups.length === 0) {
|
|
74
74
|
throw Context.auth.error();
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
if (groups === 'all') {
|
|
78
|
-
if (await Context.auth.hasFullAccess(organization.id)) {
|
|
78
|
+
if (await Context.auth.hasFullAccess(organization.id, permissionLevel)) {
|
|
79
79
|
// Can access full history for now
|
|
80
80
|
scopeFilter = {
|
|
81
81
|
registrations: {
|
|
@@ -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,
|
|
@@ -233,8 +233,8 @@ export class GetMembersEndpoint extends Endpoint<Params, Query, Body, ResponseBo
|
|
|
233
233
|
return query;
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
-
static async buildData(requestQuery: LimitedFilteredRequest) {
|
|
237
|
-
const query = await GetMembersEndpoint.buildQuery(requestQuery);
|
|
236
|
+
static async buildData(requestQuery: LimitedFilteredRequest, permissionLevel = PermissionLevel.Read) {
|
|
237
|
+
const query = await GetMembersEndpoint.buildQuery(requestQuery, permissionLevel);
|
|
238
238
|
let data: SQLResultNamespacedRow[];
|
|
239
239
|
|
|
240
240
|
try {
|
|
@@ -263,7 +263,7 @@ export class GetMembersEndpoint extends Endpoint<Params, Query, Body, ResponseBo
|
|
|
263
263
|
const members = memberIds.map(id => _members.find(m => m.id === id)!);
|
|
264
264
|
|
|
265
265
|
for (const member of members) {
|
|
266
|
-
if (!await Context.auth.canAccessMember(member,
|
|
266
|
+
if (!await Context.auth.canAccessMember(member, permissionLevel)) {
|
|
267
267
|
throw Context.auth.error();
|
|
268
268
|
}
|
|
269
269
|
}
|
|
@@ -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
|
}
|
|
@@ -133,9 +133,7 @@ export class SetOrganizationDomainEndpoint extends Endpoint<Params, Query, Body,
|
|
|
133
133
|
value: STAMHOOFD.domains.registrationCname + '.',
|
|
134
134
|
}));
|
|
135
135
|
}
|
|
136
|
-
}
|
|
137
136
|
|
|
138
|
-
if (request.body.mailDomain !== null) {
|
|
139
137
|
let priv: string;
|
|
140
138
|
let pub: string;
|
|
141
139
|
|
|
@@ -158,9 +156,18 @@ export class SetOrganizationDomainEndpoint extends Endpoint<Params, Query, Body,
|
|
|
158
156
|
// DKIM records
|
|
159
157
|
organization.privateMeta.dnsRecords.push(DNSRecord.create({
|
|
160
158
|
type: DNSRecordType.TXT,
|
|
161
|
-
name: '
|
|
159
|
+
name: Formatter.slug(STAMHOOFD.platformName) + '._domainkey.' + organization.privateMeta.pendingMailDomain + '.',
|
|
162
160
|
value: 'v=DKIM1; k=rsa; p=' + pub + '',
|
|
163
161
|
}));
|
|
162
|
+
|
|
163
|
+
// DMARC records
|
|
164
|
+
organization.privateMeta.dnsRecords.push(DNSRecord.create({
|
|
165
|
+
type: DNSRecordType.TXT,
|
|
166
|
+
name: '_dmarc.' + organization.privateMeta.pendingMailDomain + '.',
|
|
167
|
+
value: 'v=DMARC1; p=quarantine; pct=100; sp=quarantine; aspf=r; adkim=r;',
|
|
168
|
+
description: 'Opgelet met het instellen van deze DMARC-record voor je domeinnaam. Mogelijks bestaat er al een record met deze naam, voeg deze dan zeker niet dubbel toe en behoud best de huidige waarde (wel zou aspf en adkim op r moeten staan). De waarde die we voorstellen zorgt voor een sterke beveiliging, maar kan mogelijks problemen veroorzaken als je andere diensten gebruikt die op een onveilige manier emails versturen (zonder DKIM of SPF).',
|
|
169
|
+
optional: true,
|
|
170
|
+
}));
|
|
164
171
|
}
|
|
165
172
|
else {
|
|
166
173
|
if (oldMailDomain) {
|
|
@@ -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;
|
|
@@ -1,10 +1,10 @@
|
|
|
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, EmailTemplate, Event, EventNotification, Group, Member, MemberPlatformMembership, MemberWithRegistrations, Order, Organization, OrganizationRegistrationPeriod, Payment, Registration, User, Webshop } from '@stamhoofd/models';
|
|
4
|
-
import { AccessRight, EventPermissionChecker, FinancialSupportSettings, GroupCategory, GroupStatus, GroupType, MemberWithRegistrationsBlob, PermissionLevel, PermissionsResourceType, Platform as PlatformStruct,
|
|
4
|
+
import { AccessRight, EventPermissionChecker, FinancialSupportSettings, GroupCategory, GroupStatus, GroupType, MemberWithRegistrationsBlob, PermissionLevel, PermissionsResourceType, Platform as PlatformStruct, RecordSettings } from '@stamhoofd/structures';
|
|
5
5
|
import { Formatter } from '@stamhoofd/utility';
|
|
6
|
-
import { addTemporaryMemberAccess, hasTemporaryMemberAccess } from './TemporaryMemberAccess';
|
|
7
6
|
import { MemberRecordStore } from '../services/MemberRecordStore';
|
|
7
|
+
import { addTemporaryMemberAccess, hasTemporaryMemberAccess } from './TemporaryMemberAccess';
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* One class with all the responsabilities of checking permissions to each resource in the system by a given user, possibly in an organization context.
|
|
@@ -1091,7 +1091,7 @@ export class AdminPermissionChecker {
|
|
|
1091
1091
|
}
|
|
1092
1092
|
|
|
1093
1093
|
async getAccessibleGroups(organizationId: string, level: PermissionLevel = PermissionLevel.Read): Promise<string[] | 'all'> {
|
|
1094
|
-
if (await this.hasFullAccess(organizationId)) {
|
|
1094
|
+
if (await this.hasFullAccess(organizationId, level)) {
|
|
1095
1095
|
return 'all';
|
|
1096
1096
|
}
|
|
1097
1097
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/no-unsafe-argument */
|
|
2
2
|
|
|
3
3
|
import { Email, EmailAddress, EmailInterface, EmailInterfaceRecipient } from '@stamhoofd/email';
|
|
4
|
-
import { Organization } from '@stamhoofd/models';
|
|
4
|
+
import { Organization, Platform } from '@stamhoofd/models';
|
|
5
5
|
import { Formatter } from '@stamhoofd/utility';
|
|
6
6
|
import { simpleParser } from 'mailparser';
|
|
7
7
|
|
|
@@ -66,7 +66,8 @@ export class ForwardHandler {
|
|
|
66
66
|
// Send a new e-mail
|
|
67
67
|
let defaultEmail: EmailInterfaceRecipient[] = [Email.getWebmasterToEmail()];
|
|
68
68
|
let organizationEmails: EmailInterfaceRecipient[] = [];
|
|
69
|
-
const
|
|
69
|
+
const platform = await Platform.getShared();
|
|
70
|
+
const extraDescription = 'Dit bericht werd verstuurd naar ' + email + ', en werd automatisch doorgestuurd naar alle beheerders. Stel in ' + platform.config.name + ' de e-mailadressen in om ervoor te zorgen dat antwoorden naar een specifiek e-mailadres worden verstuurd.';
|
|
70
71
|
|
|
71
72
|
function doBounce() {
|
|
72
73
|
if (!from) {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { BalanceItem } from '@stamhoofd/models';
|
|
2
|
+
import { BalanceItemType, MemberWithRegistrationsBlob } from '@stamhoofd/structures';
|
|
3
|
+
import { BalanceItemService } from '../services/BalanceItemService';
|
|
4
|
+
|
|
5
|
+
export class MemberCharger {
|
|
6
|
+
static async chargeMany({ chargingOrganizationId, membersToCharge, price, amount, description, dueAt, createdAt }: { chargingOrganizationId: string; membersToCharge: MemberWithRegistrationsBlob[]; price: number; amount?: number; description: string; dueAt: Date | null; createdAt: Date | null }) {
|
|
7
|
+
const balanceItems = membersToCharge.map(memberBeingCharged => MemberCharger.createBalanceItem({
|
|
8
|
+
price,
|
|
9
|
+
amount,
|
|
10
|
+
description,
|
|
11
|
+
chargingOrganizationId,
|
|
12
|
+
memberBeingCharged,
|
|
13
|
+
dueAt,
|
|
14
|
+
createdAt,
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
await Promise.all(balanceItems.map(balanceItem => balanceItem.save()));
|
|
18
|
+
await BalanceItem.updateOutstanding(balanceItems);
|
|
19
|
+
|
|
20
|
+
// Reallocate
|
|
21
|
+
await BalanceItemService.reallocate(balanceItems, chargingOrganizationId);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
private static createBalanceItem({ price, amount, description, chargingOrganizationId, memberBeingCharged, dueAt, createdAt }: { price: number; amount?: number; description: string; chargingOrganizationId: string; memberBeingCharged: MemberWithRegistrationsBlob; dueAt: Date | null; createdAt: Date | null }): BalanceItem {
|
|
25
|
+
const balanceItem = new BalanceItem();
|
|
26
|
+
balanceItem.unitPrice = price;
|
|
27
|
+
balanceItem.amount = amount ?? 1;
|
|
28
|
+
balanceItem.description = description;
|
|
29
|
+
balanceItem.type = BalanceItemType.Other;
|
|
30
|
+
balanceItem.memberId = memberBeingCharged.id;
|
|
31
|
+
balanceItem.organizationId = chargingOrganizationId;
|
|
32
|
+
balanceItem.dueAt = dueAt;
|
|
33
|
+
if (createdAt !== null) {
|
|
34
|
+
balanceItem.createdAt = createdAt;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return balanceItem;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -1,32 +1,17 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { BalanceItem, Platform } from '@stamhoofd/models';
|
|
1
|
+
import { BalanceItem } from '@stamhoofd/models';
|
|
3
2
|
import { BalanceItemType, Organization as OrganizationStruct } from '@stamhoofd/structures';
|
|
4
3
|
import { BalanceItemService } from '../services/BalanceItemService';
|
|
5
4
|
|
|
6
5
|
export class OrganizationCharger {
|
|
7
|
-
static async
|
|
8
|
-
const platform = await Platform.getShared();
|
|
9
|
-
|
|
10
|
-
const chargeVia = platform.membershipOrganizationId;
|
|
11
|
-
|
|
12
|
-
if (!chargeVia) {
|
|
13
|
-
throw new SimpleError({
|
|
14
|
-
code: 'missing_membership_organization',
|
|
15
|
-
message: 'Missing membershipOrganizationId',
|
|
16
|
-
human: 'Er is geen lokale groep verantwoordelijk voor de aanrekening van aansluitingen geconfigureerd',
|
|
17
|
-
});
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
await OrganizationCharger.chargeMany({ chargingOrganizationId: chargeVia, ...args });
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
static async chargeMany({ chargingOrganizationId, organizationsToCharge, price, amount, description }: { chargingOrganizationId: string; organizationsToCharge: OrganizationStruct[]; price: number; amount?: number; description: string }) {
|
|
6
|
+
static async chargeMany({ chargingOrganizationId, organizationsToCharge, price, amount, description, dueAt, createdAt }: { chargingOrganizationId: string; organizationsToCharge: OrganizationStruct[]; price: number; amount?: number; description: string; dueAt: Date | null; createdAt: Date | null }) {
|
|
24
7
|
const balanceItems = organizationsToCharge.map(organizationBeingCharged => OrganizationCharger.createBalanceItem({
|
|
25
8
|
price,
|
|
26
9
|
amount,
|
|
27
10
|
description,
|
|
28
11
|
chargingOrganizationId,
|
|
29
12
|
organizationBeingCharged,
|
|
13
|
+
dueAt,
|
|
14
|
+
createdAt,
|
|
30
15
|
}));
|
|
31
16
|
|
|
32
17
|
await Promise.all(balanceItems.map(balanceItem => balanceItem.save()));
|
|
@@ -36,7 +21,7 @@ export class OrganizationCharger {
|
|
|
36
21
|
await BalanceItemService.reallocate(balanceItems, chargingOrganizationId);
|
|
37
22
|
}
|
|
38
23
|
|
|
39
|
-
private static createBalanceItem({ price, amount, description, chargingOrganizationId, organizationBeingCharged }: { price: number; amount?: number; description: string; chargingOrganizationId: string; organizationBeingCharged: OrganizationStruct }): BalanceItem {
|
|
24
|
+
private static createBalanceItem({ price, amount, description, chargingOrganizationId, organizationBeingCharged, dueAt, createdAt }: { price: number; amount?: number; description: string; chargingOrganizationId: string; organizationBeingCharged: OrganizationStruct; dueAt: Date | null; createdAt: Date | null }): BalanceItem {
|
|
40
25
|
const balanceItem = new BalanceItem();
|
|
41
26
|
balanceItem.unitPrice = price;
|
|
42
27
|
balanceItem.amount = amount ?? 1;
|
|
@@ -44,6 +29,10 @@ export class OrganizationCharger {
|
|
|
44
29
|
balanceItem.type = BalanceItemType.Other;
|
|
45
30
|
balanceItem.payingOrganizationId = organizationBeingCharged.id;
|
|
46
31
|
balanceItem.organizationId = chargingOrganizationId;
|
|
32
|
+
balanceItem.dueAt = dueAt;
|
|
33
|
+
if (createdAt !== null) {
|
|
34
|
+
balanceItem.createdAt = createdAt;
|
|
35
|
+
}
|
|
47
36
|
|
|
48
37
|
return balanceItem;
|
|
49
38
|
}
|
|
@@ -171,6 +171,7 @@ export class EventNotificationService {
|
|
|
171
171
|
|
|
172
172
|
static async sendSubmitterEmail(type: EmailTemplateType, notification: EventNotification) {
|
|
173
173
|
if (notification.endDate < new Date()) {
|
|
174
|
+
console.log('Skipped submitter email because it is in the past');
|
|
174
175
|
// Ignore
|
|
175
176
|
return;
|
|
176
177
|
}
|
|
@@ -186,6 +187,8 @@ export class EventNotificationService {
|
|
|
186
187
|
|
|
187
188
|
static async sendReviewerEmail(type: EmailTemplateType, notification: EventNotification) {
|
|
188
189
|
if (notification.endDate < new Date()) {
|
|
190
|
+
console.log('Skipped reviewer email because it is in the past');
|
|
191
|
+
|
|
189
192
|
// Ignore
|
|
190
193
|
return;
|
|
191
194
|
}
|