@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.
@@ -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(event.startDate);
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, 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
 
@@ -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(PermissionLevel.Read);
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, PermissionLevel.Read)) {
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 == 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
  }
@@ -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: 'stamhoofd._domainkey.' + request.body.mailDomain + '.',
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, RecordCategory, RecordSettings } from '@stamhoofd/structures';
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 extraDescription = 'Dit bericht werd verstuurd naar ' + email + ', en werd automatisch doorgestuurd naar alle beheerders. Stel in Stamhoofd de e-mailadressen in om ervoor te zorgen dat antwoorden naar een specifiek e-mailadres worden verstuurd.';
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 { SimpleError } from '@simonbackx/simple-errors';
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 chargeFromPlatform(args: { organizationsToCharge: OrganizationStruct[]; price: number; amount?: number; description: string }) {
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
  }