@stamhoofd/backend 2.42.3 → 2.43.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stamhoofd/backend",
3
- "version": "2.42.3",
3
+ "version": "2.43.1",
4
4
  "main": "./dist/index.js",
5
5
  "exports": {
6
6
  ".": {
@@ -36,14 +36,14 @@
36
36
  "@simonbackx/simple-encoding": "2.15.1",
37
37
  "@simonbackx/simple-endpoints": "1.14.0",
38
38
  "@simonbackx/simple-logging": "^1.0.1",
39
- "@stamhoofd/backend-i18n": "2.42.3",
40
- "@stamhoofd/backend-middleware": "2.42.3",
41
- "@stamhoofd/email": "2.42.3",
42
- "@stamhoofd/models": "2.42.3",
43
- "@stamhoofd/queues": "2.42.3",
44
- "@stamhoofd/sql": "2.42.3",
45
- "@stamhoofd/structures": "2.42.3",
46
- "@stamhoofd/utility": "2.42.3",
39
+ "@stamhoofd/backend-i18n": "2.43.1",
40
+ "@stamhoofd/backend-middleware": "2.43.1",
41
+ "@stamhoofd/email": "2.43.1",
42
+ "@stamhoofd/models": "2.43.1",
43
+ "@stamhoofd/queues": "2.43.1",
44
+ "@stamhoofd/sql": "2.43.1",
45
+ "@stamhoofd/structures": "2.43.1",
46
+ "@stamhoofd/utility": "2.43.1",
47
47
  "archiver": "^7.0.1",
48
48
  "aws-sdk": "^2.885.0",
49
49
  "axios": "1.6.8",
@@ -60,5 +60,5 @@
60
60
  "postmark": "^4.0.5",
61
61
  "stripe": "^16.6.0"
62
62
  },
63
- "gitHead": "ee0812960b3c22f3c13c15b0b5a84639aeba5ad8"
63
+ "gitHead": "49f9a42eb52688f4f4d417871da7448969623013"
64
64
  }
@@ -52,6 +52,15 @@ export class PatchEventsEndpoint extends Endpoint<Params, Query, Body, ResponseB
52
52
  const event = new Event();
53
53
  event.organizationId = put.organizationId;
54
54
  event.meta = put.meta;
55
+
56
+ if (event.meta.groups && event.meta.groups.length === 0) {
57
+ throw new SimpleError({
58
+ code: 'invalid_field',
59
+ message: 'Empty groups',
60
+ human: 'Kies minstens één leeftijdsgroep',
61
+ });
62
+ }
63
+
55
64
  const eventOrganization = await this.checkEventAccess(event);
56
65
  event.id = put.id;
57
66
  event.name = put.name;
@@ -122,6 +131,14 @@ export class PatchEventsEndpoint extends Endpoint<Params, Query, Body, ResponseB
122
131
  event.organizationId = patch.organizationId;
123
132
  }
124
133
 
134
+ if (event.meta.groups && event.meta.groups.length === 0) {
135
+ throw new SimpleError({
136
+ code: 'invalid_field',
137
+ message: 'Empty groups',
138
+ human: 'Kies minstens één leeftijdsgroep',
139
+ });
140
+ }
141
+
125
142
  const eventOrganization = await this.checkEventAccess(event);
126
143
  if (eventOrganization) {
127
144
  event.meta.organizationCache = NamedObject.create({ id: eventOrganization.id, name: eventOrganization.name });
@@ -2,10 +2,11 @@ import { OneToManyRelation } from '@simonbackx/simple-database';
2
2
  import { ConvertArrayToPatchableArray, Decoder, PatchableArrayAutoEncoder, PatchableArrayDecoder, StringDecoder } from '@simonbackx/simple-encoding';
3
3
  import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
4
4
  import { SimpleError } from '@simonbackx/simple-errors';
5
- import { BalanceItem, Document, Group, Member, MemberFactory, MemberPlatformMembership, MemberResponsibilityRecord, MemberWithRegistrations, Organization, Platform, Registration, SetupStepUpdater, User } from '@stamhoofd/models';
6
- import { GroupType, MemberWithRegistrationsBlob, MembersBlob, PermissionLevel } from '@stamhoofd/structures';
5
+ import { BalanceItem, Document, Group, Member, MemberFactory, MemberPlatformMembership, MemberResponsibilityRecord, MemberWithRegistrations, mergeTwoMembers, Organization, Platform, RateLimiter, Registration, SetupStepUpdater, User } from '@stamhoofd/models';
6
+ import { GroupType, MembersBlob, MemberWithRegistrationsBlob, PermissionLevel } from '@stamhoofd/structures';
7
7
  import { Formatter } from '@stamhoofd/utility';
8
8
 
9
+ import { Email } from '@stamhoofd/email';
9
10
  import { QueueHandler } from '@stamhoofd/queues';
10
11
  import { AuthenticatedStructures } from '../../../helpers/AuthenticatedStructures';
11
12
  import { Context } from '../../../helpers/Context';
@@ -17,6 +18,16 @@ type Query = undefined;
17
18
  type Body = PatchableArrayAutoEncoder<MemberWithRegistrationsBlob>;
18
19
  type ResponseBody = MembersBlob;
19
20
 
21
+ export const securityCodeLimiter = new RateLimiter({
22
+ limits: [
23
+ {
24
+ // Max 10 a day
25
+ limit: 10,
26
+ duration: 24 * 60 * 1000 * 60,
27
+ },
28
+ ],
29
+ });
30
+
20
31
  /**
21
32
  * One endpoint to create, patch and delete members and their registrations and payments
22
33
  */
@@ -92,33 +103,15 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
92
103
  struct.details.cleanData();
93
104
  member.details = struct.details;
94
105
 
95
- const duplicate = await PatchOrganizationMembersEndpoint.checkDuplicate(member);
106
+ const duplicate = await PatchOrganizationMembersEndpoint.checkDuplicate(member, struct.details.securityCode);
96
107
  if (duplicate) {
97
108
  // Merge data
98
- duplicate.details.merge(member.details);
99
109
  member = duplicate;
100
-
101
- // You need write permissions, because a user can potentially earn write permissions on a member
102
- // by registering it
103
- if (!await Context.auth.canAccessMember(duplicate, PermissionLevel.Write)) {
104
- throw new SimpleError({
105
- code: 'known_member_missing_rights',
106
- message: 'Creating known member without sufficient access rights',
107
- human: 'Dit lid is al bekend in het systeem, maar je hebt er geen toegang tot. Vraag iemand met de juiste toegangsrechten om dit lid voor jou toe te voegen, of vraag het lid om zelf in te schrijven via het ledenportaal.',
108
- statusCode: 400,
109
- });
110
- }
111
110
  }
112
111
 
113
112
  // We risk creating a new member without being able to access it manually afterwards
114
- if ((organization && !await Context.auth.hasFullAccess(organization.id)) || (!organization && !Context.auth.hasPlatformFullAccess())) {
115
- throw new SimpleError({
116
- code: 'missing_group',
117
- message: 'Missing group',
118
- human: 'Je moet hoofdbeheerder zijn om een lid toe te voegen in het systeem',
119
- statusCode: 400,
120
- });
121
- }
113
+ // Cache access to this member temporarily in memory
114
+ await Context.auth.temporarilyGrantMemberAccess(member, PermissionLevel.Write);
122
115
 
123
116
  if (STAMHOOFD.userMode !== 'platform' && !member.organizationId) {
124
117
  throw new SimpleError({
@@ -132,8 +125,8 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
132
125
  /**
133
126
  * In development mode, we allow some secret usernames to create fake data
134
127
  */
135
- if ((STAMHOOFD.environment == 'development' || STAMHOOFD.environment == 'staging') && organization) {
136
- if (member.details.firstName.toLocaleLowerCase() == 'create' && parseInt(member.details.lastName) > 0) {
128
+ if ((STAMHOOFD.environment === 'development' || STAMHOOFD.environment === 'staging') && organization) {
129
+ if (member.details.firstName.toLocaleLowerCase() === 'create' && parseInt(member.details.lastName) > 0) {
137
130
  const count = parseInt(member.details.lastName);
138
131
  await this.createDummyMembers(organization, count);
139
132
 
@@ -155,9 +148,17 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
155
148
  // Loop all members one by one
156
149
  for (let patch of request.body.getPatches()) {
157
150
  const member = members.find(m => m.id === patch.id) ?? await Member.getWithRegistrations(patch.id);
158
- if (!member || !await Context.auth.canAccessMember(member, PermissionLevel.Write)) {
151
+ const securityCode = patch.details?.securityCode; // will get cleared after the filter
152
+
153
+ if (!member) {
159
154
  throw Context.auth.notFoundOrNoAccess('Je hebt geen toegang tot dit lid of het bestaat niet');
160
155
  }
156
+
157
+ if (!await Context.auth.canAccessMember(member, PermissionLevel.Write)) {
158
+ // Still allowed if you provide a security code
159
+ await PatchOrganizationMembersEndpoint.checkSecurityCode(member, securityCode);
160
+ }
161
+
161
162
  patch = await Context.auth.filterMemberPatch(member, patch);
162
163
 
163
164
  if (patch.details) {
@@ -179,6 +180,19 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
179
180
  }
180
181
  }
181
182
 
183
+ const duplicate = await PatchOrganizationMembersEndpoint.checkDuplicate(member, securityCode);
184
+ if (duplicate) {
185
+ // Remove the member from the list
186
+ const iii = members.findIndex(m => m.id === member.id);
187
+ if (iii !== -1) {
188
+ members.splice(iii, 1);
189
+ }
190
+
191
+ // Add new
192
+ members.push(duplicate);
193
+ continue;
194
+ }
195
+
182
196
  await member.save();
183
197
 
184
198
  // Update documents
@@ -561,7 +575,7 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
561
575
  }
562
576
  }
563
577
 
564
- static async checkDuplicate(member: Member) {
578
+ static async findExistingMember(member: Member) {
565
579
  if (!member.details.birthDay) {
566
580
  return;
567
581
  }
@@ -585,6 +599,69 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
585
599
  }
586
600
  }
587
601
 
602
+ static async checkSecurityCode(member: MemberWithRegistrations, securityCode: string | null | undefined) {
603
+ if (await member.isSafeToMergeDuplicateWithoutSecurityCode() || await Context.auth.canAccessMember(member, PermissionLevel.Write)) {
604
+ console.log('checkSecurityCode: without security code: allowed for ' + member.id);
605
+ }
606
+ else if (securityCode) {
607
+ try {
608
+ securityCodeLimiter.track(member.details.name, 1);
609
+ }
610
+ catch (e) {
611
+ Email.sendWebmaster({
612
+ subject: '[Limiet] Limiet bereikt voor aantal beveiligingscodes',
613
+ text: 'Beste, \nDe limiet werd bereikt voor het aantal beveiligingscodes per dag. \nNaam lid: ' + member.details.name + ' (ID: ' + member.id + ')' + '\n\n' + e.message + '\n\nStamhoofd',
614
+ });
615
+
616
+ throw new SimpleError({
617
+ code: 'too_many_tries',
618
+ message: 'Too many securityCodes limited',
619
+ human: 'Oeps! Om spam te voorkomen limiteren we het aantal beveiligingscodes die je kan proberen. Probeer morgen opnieuw.',
620
+ field: 'details.securityCode',
621
+ });
622
+ }
623
+
624
+ // Entered the security code, so we can link the user to the member
625
+ if (STAMHOOFD.environment !== 'development') {
626
+ if (!member.details.securityCode || securityCode !== member.details.securityCode) {
627
+ throw new SimpleError({
628
+ code: 'invalid_field',
629
+ field: 'details.securityCode',
630
+ message: 'Invalid security code',
631
+ human: Context.i18n.$t('49753d6a-7ca4-4145-8024-0be05a9ab063'),
632
+ statusCode: 400,
633
+ });
634
+ }
635
+ }
636
+
637
+ console.log('checkSecurityCode: security code is correct - for ' + member.id);
638
+
639
+ // Grant temporary access to this member without needing to enter the security code again
640
+ await Context.auth.temporarilyGrantMemberAccess(member, PermissionLevel.Write);
641
+ }
642
+ else {
643
+ throw new SimpleError({
644
+ code: 'known_member_missing_rights',
645
+ message: 'Creating known member without sufficient access rights',
646
+ human: `${member.details.firstName} is al gekend in ons systeem, maar jouw e-mailadres niet. Om toegang te krijgen heb je de beveiligingscode nodig.`,
647
+ statusCode: 400,
648
+ });
649
+ }
650
+ }
651
+
652
+ static async checkDuplicate(member: Member, securityCode: string | null | undefined) {
653
+ // Check for duplicates and prevent creating a duplicate member by a user
654
+ const duplicate = await this.findExistingMember(member);
655
+ if (duplicate) {
656
+ await this.checkSecurityCode(duplicate, securityCode);
657
+
658
+ // Merge data
659
+ // NOTE: We use mergeTwoMembers instead of mergeMultipleMembers, because we should never safe 'member' , because that one does not exist in the database
660
+ await mergeTwoMembers(duplicate, member);
661
+ return duplicate;
662
+ }
663
+ }
664
+
588
665
  async createDummyMembers(organization: Organization, count: number) {
589
666
  await new MemberFactory({
590
667
  organization,
@@ -1,10 +1,9 @@
1
1
  import { AutoEncoderPatchType, Decoder, PatchableArrayAutoEncoder, PatchableArrayDecoder, StringDecoder } from '@simonbackx/simple-encoding';
2
2
  import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
3
3
  import { SimpleError } from '@simonbackx/simple-errors';
4
- import { Document, Member, mergeTwoMembers, RateLimiter } from '@stamhoofd/models';
4
+ import { Document, Member, RateLimiter } from '@stamhoofd/models';
5
5
  import { MemberDetails, MembersBlob, MemberWithRegistrationsBlob } from '@stamhoofd/structures';
6
6
 
7
- import { Email } from '@stamhoofd/email';
8
7
  import { AuthenticatedStructures } from '../../../helpers/AuthenticatedStructures';
9
8
  import { Context } from '../../../helpers/Context';
10
9
  import { MemberUserSyncer } from '../../../helpers/MemberUserSyncer';
@@ -61,7 +60,7 @@ export class PatchUserMembersEndpoint extends Endpoint<Params, Query, Body, Resp
61
60
 
62
61
  this.throwIfInvalidDetails(member.details);
63
62
 
64
- const duplicate = await this.checkDuplicate(member, struct.details.securityCode);
63
+ const duplicate = await PatchOrganizationMembersEndpoint.checkDuplicate(member, struct.details.securityCode);
65
64
  if (duplicate) {
66
65
  addedMembers.push(duplicate);
67
66
  continue;
@@ -75,7 +74,7 @@ export class PatchUserMembersEndpoint extends Endpoint<Params, Query, Body, Resp
75
74
  let members = await Member.getMembersWithRegistrationForUser(user);
76
75
 
77
76
  for (let struct of request.body.getPatches()) {
78
- const member = members.find(m => m.id == struct.id);
77
+ const member = members.find(m => m.id === struct.id);
79
78
  if (!member) {
80
79
  throw new SimpleError({
81
80
  code: 'invalid_member',
@@ -110,15 +109,15 @@ export class PatchUserMembersEndpoint extends Endpoint<Params, Query, Body, Resp
110
109
  });
111
110
  }
112
111
 
113
- /* const duplicate = await this.checkDuplicate(member, securityCode)
112
+ const duplicate = await PatchOrganizationMembersEndpoint.checkDuplicate(member, securityCode);
114
113
  if (duplicate) {
115
114
  // Remove the member from the list
116
- members.splice(members.findIndex(m => m.id === member.id), 1)
115
+ members.splice(members.findIndex(m => m.id === member.id), 1);
117
116
 
118
117
  // Add new
119
- addedMembers.push(duplicate)
120
- continue
121
- } */
118
+ addedMembers.push(duplicate);
119
+ continue;
120
+ }
122
121
 
123
122
  await member.save();
124
123
  await MemberUserSyncer.onChangeMember(member);
@@ -157,62 +156,6 @@ export class PatchUserMembersEndpoint extends Endpoint<Params, Query, Body, Resp
157
156
  );
158
157
  }
159
158
 
160
- async checkDuplicate(member: Member, securityCode: string | null | undefined) {
161
- // Check for duplicates and prevent creating a duplicate member by a user
162
- const duplicate = await PatchOrganizationMembersEndpoint.checkDuplicate(member);
163
- if (duplicate) {
164
- if (await duplicate.isSafeToMergeDuplicateWithoutSecurityCode()) {
165
- console.log('Merging duplicate without security code: allowed for ' + duplicate.id);
166
- }
167
- else if (securityCode) {
168
- try {
169
- securityCodeLimiter.track(member.details.name, 1);
170
- }
171
- catch (e) {
172
- Email.sendWebmaster({
173
- subject: '[Limiet] Limiet bereikt voor aantal beveiligingscodes',
174
- text: 'Beste, \nDe limiet werd bereikt voor het aantal beveiligingscodes per dag. \nNaam lid: ' + member.details.name + ' (ID: ' + duplicate.id + ')' + '\n\n' + e.message + '\n\nStamhoofd',
175
- });
176
-
177
- throw new SimpleError({
178
- code: 'too_many_tries',
179
- message: 'Too many securityCodes limited',
180
- human: 'Oeps! Om spam te voorkomen limiteren we het aantal beveiligingscodes die je kan proberen. Probeer morgen opnieuw.',
181
- field: 'details.securityCode',
182
- });
183
- }
184
-
185
- // Entered the security code, so we can link the user to the member
186
- if (STAMHOOFD.environment !== 'development') {
187
- if (!duplicate.details.securityCode || securityCode !== duplicate.details.securityCode) {
188
- throw new SimpleError({
189
- code: 'invalid_field',
190
- field: 'details.securityCode',
191
- message: 'Invalid security code',
192
- human: Context.i18n.$t('49753d6a-7ca4-4145-8024-0be05a9ab063'),
193
- statusCode: 400,
194
- });
195
- }
196
- }
197
-
198
- console.log('Merging duplicate: security code is correct - for ' + duplicate.id);
199
- }
200
- else {
201
- throw new SimpleError({
202
- code: 'known_member_missing_rights',
203
- message: 'Creating known member without sufficient access rights',
204
- human: `${member.details.firstName} is al gekend in ons systeem, maar jouw e-mailadres niet. Om toegang te krijgen heb je de beveiligingscode nodig.`,
205
- statusCode: 400,
206
- });
207
- }
208
-
209
- // Merge data
210
- // NOTE: We use mergeTwoMembers instead of mergeMultipleMembers, because we should never safe 'member' , because that one does not exist in the database
211
- await mergeTwoMembers(duplicate, member);
212
- return duplicate;
213
- }
214
- }
215
-
216
159
  private throwIfInvalidDetails(details: MemberDetails) {
217
160
  if (details.firstName.length < 2) {
218
161
  throw new SimpleError({
@@ -260,6 +260,7 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
260
260
  registration.price = 0; // will get filled by balance items themselves
261
261
  registration.groupPrice = item.groupPrice;
262
262
  registration.options = item.options;
263
+ registration.recordAnswers = item.recordAnswers;
263
264
 
264
265
  payRegistrations.push({
265
266
  registration,
@@ -0,0 +1,43 @@
1
+ import { Decoder } from '@simonbackx/simple-encoding';
2
+ import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
3
+ import { CountFilteredRequest, CountResponse } from '@stamhoofd/structures';
4
+
5
+ import { Context } from '../../../../helpers/Context';
6
+ import { GetCachedOutstandingBalanceEndpoint } from './GetCachedOutstandingBalanceEndpoint';
7
+
8
+ type Params = Record<string, never>;
9
+ type Query = CountFilteredRequest;
10
+ type Body = undefined;
11
+ type ResponseBody = CountResponse;
12
+
13
+ export class GetCachedOutstandingBalanceCountEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
14
+ queryDecoder = CountFilteredRequest as Decoder<CountFilteredRequest>;
15
+
16
+ protected doesMatch(request: Request): [true, Params] | [false] {
17
+ if (request.method !== 'GET') {
18
+ return [false];
19
+ }
20
+
21
+ const params = Endpoint.parseParameters(request.url, '/cached-outstanding-balance/count', {});
22
+
23
+ if (params) {
24
+ return [true, params as Params];
25
+ }
26
+ return [false];
27
+ }
28
+
29
+ async handle(request: DecodedRequest<Params, Query, Body>) {
30
+ await Context.setOrganizationScope();
31
+ await Context.authenticate();
32
+ const query = await GetCachedOutstandingBalanceEndpoint.buildQuery(request.query);
33
+
34
+ const count = await query
35
+ .count();
36
+
37
+ return new Response(
38
+ CountResponse.create({
39
+ count,
40
+ }),
41
+ );
42
+ }
43
+ }
@@ -0,0 +1,142 @@
1
+ import { Decoder } from '@simonbackx/simple-encoding';
2
+ import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
3
+ import { SimpleError } from '@simonbackx/simple-errors';
4
+ import { CachedOutstandingBalance } from '@stamhoofd/models';
5
+ import { compileToSQLFilter, compileToSQLSorter } from '@stamhoofd/sql';
6
+ import { CachedOutstandingBalance as CachedOutstandingBalanceStruct, CountFilteredRequest, LimitedFilteredRequest, PaginatedResponse, StamhoofdFilter, assertSort, getSortFilter } from '@stamhoofd/structures';
7
+
8
+ import { AuthenticatedStructures } from '../../../../helpers/AuthenticatedStructures';
9
+ import { Context } from '../../../../helpers/Context';
10
+ import { cachedOutstandingBalanceFilterCompilers } from '../../../../sql-filters/cached-outstanding-balance';
11
+ import { cachedOutstandingBalanceSorters } from '../../../../sql-sorters/cached-outstanding-balance';
12
+
13
+ type Params = Record<string, never>;
14
+ type Query = LimitedFilteredRequest;
15
+ type Body = undefined;
16
+ type ResponseBody = PaginatedResponse<CachedOutstandingBalanceStruct[], LimitedFilteredRequest>;
17
+
18
+ const sorters = cachedOutstandingBalanceSorters;
19
+ const filterCompilers = cachedOutstandingBalanceFilterCompilers;
20
+
21
+ export class GetCachedOutstandingBalanceEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
22
+ queryDecoder = LimitedFilteredRequest as Decoder<LimitedFilteredRequest>;
23
+
24
+ protected doesMatch(request: Request): [true, Params] | [false] {
25
+ if (request.method !== 'GET') {
26
+ return [false];
27
+ }
28
+
29
+ const params = Endpoint.parseParameters(request.url, '/cached-outstanding-balance', {});
30
+
31
+ if (params) {
32
+ return [true, params as Params];
33
+ }
34
+ return [false];
35
+ }
36
+
37
+ static async buildQuery(q: CountFilteredRequest | LimitedFilteredRequest) {
38
+ const organization = Context.organization;
39
+ let scopeFilter: StamhoofdFilter | undefined = undefined;
40
+
41
+ if (!organization) {
42
+ throw Context.auth.error();
43
+ }
44
+
45
+ if (!await Context.auth.canManageFinances(organization.id)) {
46
+ throw Context.auth.error();
47
+ }
48
+
49
+ scopeFilter = {
50
+ organizationId: organization.id,
51
+ };
52
+
53
+ const query = CachedOutstandingBalance
54
+ .select();
55
+
56
+ if (scopeFilter) {
57
+ query.where(compileToSQLFilter(scopeFilter, filterCompilers));
58
+ }
59
+
60
+ if (q.filter) {
61
+ query.where(compileToSQLFilter(q.filter, filterCompilers));
62
+ }
63
+
64
+ if (q.search) {
65
+ throw new SimpleError({
66
+ code: 'not_implemented',
67
+ message: 'Zoeken in openstaande bedragen is voorlopig nog niet mogelijk',
68
+ });
69
+ }
70
+
71
+ if (q instanceof LimitedFilteredRequest) {
72
+ if (q.pageFilter) {
73
+ query.where(compileToSQLFilter(q.pageFilter, filterCompilers));
74
+ }
75
+
76
+ q.sort = assertSort(q.sort, [{ key: 'id' }]);
77
+ query.orderBy(compileToSQLSorter(q.sort, sorters));
78
+ query.limit(q.limit);
79
+ }
80
+
81
+ return query;
82
+ }
83
+
84
+ static async buildData(requestQuery: LimitedFilteredRequest) {
85
+ const query = await GetCachedOutstandingBalanceEndpoint.buildQuery(requestQuery);
86
+ const data = await query.fetch();
87
+
88
+ // todo: Create objects from data
89
+
90
+ let next: LimitedFilteredRequest | undefined;
91
+
92
+ if (data.length >= requestQuery.limit) {
93
+ const lastObject = data[data.length - 1];
94
+ const nextFilter = getSortFilter(lastObject, sorters, requestQuery.sort);
95
+
96
+ next = new LimitedFilteredRequest({
97
+ filter: requestQuery.filter,
98
+ pageFilter: nextFilter,
99
+ sort: requestQuery.sort,
100
+ limit: requestQuery.limit,
101
+ search: requestQuery.search,
102
+ });
103
+
104
+ if (JSON.stringify(nextFilter) === JSON.stringify(requestQuery.pageFilter)) {
105
+ console.error('Found infinite loading loop for', requestQuery);
106
+ next = undefined;
107
+ }
108
+ }
109
+
110
+ return new PaginatedResponse<CachedOutstandingBalanceStruct[], LimitedFilteredRequest>({
111
+ results: await AuthenticatedStructures.cachedOutstandingBalances(data),
112
+ next,
113
+ });
114
+ }
115
+
116
+ async handle(request: DecodedRequest<Params, Query, Body>) {
117
+ await Context.setOrganizationScope();
118
+ await Context.authenticate();
119
+
120
+ const maxLimit = Context.auth.hasSomePlatformAccess() ? 100 : 100;
121
+
122
+ if (request.query.limit > maxLimit) {
123
+ throw new SimpleError({
124
+ code: 'invalid_field',
125
+ field: 'limit',
126
+ message: 'Limit can not be more than ' + maxLimit,
127
+ });
128
+ }
129
+
130
+ if (request.query.limit < 1) {
131
+ throw new SimpleError({
132
+ code: 'invalid_field',
133
+ field: 'limit',
134
+ message: 'Limit can not be less than 1',
135
+ });
136
+ }
137
+
138
+ return new Response(
139
+ await GetCachedOutstandingBalanceEndpoint.buildData(request.query),
140
+ );
141
+ }
142
+ }
@@ -1,11 +1,11 @@
1
- import { AutoEncoder, Data, Decoder, EnumDecoder, field, StringDecoder } from '@simonbackx/simple-encoding';
1
+ import { AutoEncoder, Decoder, EnumDecoder, field, StringDecoder } from '@simonbackx/simple-encoding';
2
2
  import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
3
3
  import { EmailTemplate } from '@stamhoofd/models';
4
4
  import { EmailTemplate as EmailTemplateStruct, EmailTemplateType } from '@stamhoofd/structures';
5
5
 
6
- import { Context } from '../../../../helpers/Context';
7
- import { StringNullableDecoder } from '../../../../decoders/StringNullableDecoder';
8
6
  import { StringArrayDecoder } from '../../../../decoders/StringArrayDecoder';
7
+ import { StringNullableDecoder } from '../../../../decoders/StringNullableDecoder';
8
+ import { Context } from '../../../../helpers/Context';
9
9
 
10
10
  type Params = Record<string, never>;
11
11
  type Body = undefined;
@@ -71,7 +71,16 @@ export class GetEmailTemplatesEndpoint extends Endpoint<Params, Query, Body, Res
71
71
  ? await EmailTemplate.where({ webshopId: request.query.webshopId ?? null, groupId: request.query.groupIds ? { sign: 'IN', value: request.query.groupIds } : null, type: { sign: 'IN', value: types } })
72
72
  : []
73
73
  );
74
- const defaultTemplates = await EmailTemplate.where({ organizationId: null, type: { sign: 'IN', value: types } });
75
- return new Response([...templates, ...defaultTemplates].map(template => EmailTemplateStruct.create(template)));
74
+
75
+ const defaultTemplateTypes = organization ? types.filter(type => type !== EmailTemplateType.SavedMembersEmail) : types;
76
+ const defaultTemplates = await EmailTemplate.where({
77
+ organizationId: null,
78
+ type: {
79
+ sign: 'IN',
80
+ value: defaultTemplateTypes,
81
+ },
82
+ });
83
+
84
+ return new Response(templates.concat(defaultTemplates).map(template => EmailTemplateStruct.create(template)));
76
85
  }
77
86
  }
@@ -1,6 +1,5 @@
1
1
  import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
2
- import { SimpleError } from '@simonbackx/simple-errors';
3
- import { BalanceItem, Group, Member } from '@stamhoofd/models';
2
+ import { BalanceItem, Member } from '@stamhoofd/models';
4
3
  import { BalanceItemWithPayments } from '@stamhoofd/structures';
5
4
 
6
5
  import { Context } from '../../../../helpers/Context';
@@ -1,8 +1,9 @@
1
1
  import { AutoEncoderPatchType, PatchMap } from '@simonbackx/simple-encoding';
2
2
  import { SimpleError } from '@simonbackx/simple-errors';
3
3
  import { BalanceItem, CachedOutstandingBalance, Document, DocumentTemplate, EmailTemplate, Event, Group, Member, MemberPlatformMembership, MemberWithRegistrations, Order, Organization, OrganizationRegistrationPeriod, Payment, Registration, User, Webshop } from '@stamhoofd/models';
4
- import { AccessRight, FinancialSupportSettings, GroupCategory, GroupStatus, LoadedPermissions, MemberWithRegistrationsBlob, PermissionLevel, PermissionsResourceType, Platform as PlatformStruct, RecordCategory } from '@stamhoofd/structures';
4
+ import { AccessRight, EventPermissionChecker, FinancialSupportSettings, GroupCategory, GroupStatus, MemberWithRegistrationsBlob, PermissionLevel, PermissionsResourceType, Platform as PlatformStruct, RecordCategory } from '@stamhoofd/structures';
5
5
  import { Formatter } from '@stamhoofd/utility';
6
+ import { addTemporaryMemberAccess, hasTemporaryMemberAccess } from './TemporaryMemberAccess';
6
7
 
7
8
  /**
8
9
  * One class with all the responsabilities of checking permissions to each resource in the system by a given user, possibly in an organization context.
@@ -195,120 +196,11 @@ export class AdminPermissionChecker {
195
196
  * @throws error if not allowed to write this event
196
197
  */
197
198
  async checkEventAccess(event: Event): Promise<Organization | null> {
198
- const accessRight: AccessRight = AccessRight.EventWrite;
199
-
200
- // #region organization and groups
201
- if (event.organizationId !== null) {
202
- let organization: Organization | null = null;
203
- let organizationPermissions: LoadedPermissions | null = null;
204
-
205
- try {
206
- organization = await this.getOrganization(event.organizationId);
207
- organizationPermissions = await this.getOrganizationPermissions(organization);
208
- }
209
- catch (error) {
210
- console.error(error);
211
- throw new SimpleError({
212
- code: 'not_found',
213
- message: 'Event not found',
214
- human: 'De activiteit werd niet gevonden',
215
- });
216
- }
217
-
218
- if (organizationPermissions === null) {
219
- throw new SimpleError({
220
- code: 'permission_denied',
221
- message: 'Je hebt geen toegangsrechten om een activiteit te beheren voor deze organisatie.',
222
- statusCode: 403,
223
- });
224
- }
225
-
226
- if (event.meta.groups === null) {
227
- if (!organizationPermissions.hasResourceAccessRight(PermissionsResourceType.Groups, '', accessRight)) {
228
- throw new SimpleError({
229
- code: 'permission_denied',
230
- message: 'Je hebt geen toegangsrechten om een activiteit te beheren voor deze organisatie.',
231
- statusCode: 403,
232
- });
233
- }
234
- }
235
- else {
236
- for (const group of event.meta.groups) {
237
- if (!organizationPermissions.hasResourceAccessRight(PermissionsResourceType.Groups, group.id, accessRight)) {
238
- throw new SimpleError({
239
- code: 'permission_denied',
240
- message: 'Je hebt geen toegangsrechten om een activiteit te beheren voor deze groep(en).',
241
- statusCode: 403,
242
- });
243
- }
244
- }
245
- }
246
-
247
- if (event.meta.organizationTagIds !== null) {
248
- // not supported currently
249
- throw new SimpleError({
250
- code: 'invalid_field',
251
- message: 'Een activiteit voor een organisatie kan geen tags bevatten.',
252
- statusCode: 403,
253
- });
254
- }
255
-
256
- if (event.meta.defaultAgeGroupIds !== null) {
257
- // not supported currently
258
- throw new SimpleError({
259
- code: 'invalid_field',
260
- message: 'Een activiteit voor een organisatie kan niet beperkt worden tot specifieke standaard leeftijdsgroepen.',
261
- statusCode: 403,
262
- });
263
- }
264
-
265
- return organization;
266
- }
267
- // #endregion
268
-
269
- // #region platform
270
- if (event.meta.groups !== null) {
271
- // not supported currently
272
- throw new SimpleError({
273
- code: 'permission_denied',
274
- message: 'Een nationale of regionale activiteit kan (momenteel) niet beperkt worden tot specifieke groepen.',
275
- statusCode: 403,
276
- });
277
- }
278
-
279
- const platformPermissions = this.platformPermissions;
280
- if (!platformPermissions) {
281
- throw new SimpleError({
282
- code: 'permission_denied',
283
- message: 'Je hebt geen toegangsrechten om een nationale of regionale activiteit te beheren.',
284
- statusCode: 403,
285
- });
286
- }
287
-
288
- // organization tags
289
- if (event.meta.organizationTagIds === null) {
290
- if (!(platformPermissions.hasAccessRight(accessRight) || platformPermissions.hasResourceAccessRight(PermissionsResourceType.OrganizationTags, '', accessRight))) {
291
- throw new SimpleError({
292
- code: 'permission_denied',
293
- message: 'Je kan geen nationale activiteiten beheren',
294
- statusCode: 403,
295
- });
296
- }
297
- }
298
- else {
299
- for (const tagId of event.meta.organizationTagIds) {
300
- if (!platformPermissions.hasResourceAccessRight(PermissionsResourceType.OrganizationTags, tagId, accessRight)) {
301
- throw new SimpleError({
302
- code: 'permission_denied',
303
- message: "Je hebt geen toegangsrechten om een nationale of regionale activiteit te beheren voor deze regio('s).",
304
- statusCode: 403,
305
- });
306
- }
307
- }
308
- }
309
- // #endregion
310
-
311
- return null;
199
+ return await EventPermissionChecker.checkEventAccessAsync(event, {
200
+ userPermissions: this.user.permissions,
201
+ platform: this.platform,
202
+ getOrganization: async (id: string) => await this.getOrganization(id),
203
+ });
312
204
  }
313
205
 
314
206
  async canAccessArchivedGroups(organizationId: string) {
@@ -333,9 +225,9 @@ export class AdminPermissionChecker {
333
225
  return true;
334
226
  }
335
227
 
336
- if (member.registrations.length === 0 && permissionLevel !== PermissionLevel.Full && (this.organization && await this.hasFullAccess(this.organization.id, PermissionLevel.Full))) {
337
- // Everyone with at least full access to at least one organization can access this member
338
- // This allows organizations to register new members themselves
228
+ // Temporary access
229
+ if (hasTemporaryMemberAccess(this.user.id, member.id, permissionLevel)) {
230
+ console.log('User has temporary access to member', member.id, 'for user', this.user.id);
339
231
  return true;
340
232
  }
341
233
 
@@ -348,6 +240,15 @@ export class AdminPermissionChecker {
348
240
  return false;
349
241
  }
350
242
 
243
+ /**
244
+ * The server will temporarily grant the user access to this member, and store this in the server
245
+ * memory. This is required for adding new members to an organization (first add member -> then patch with registrations, which requires write access).
246
+ */
247
+ async temporarilyGrantMemberAccess(member: MemberWithRegistrations, permissionLevel: PermissionLevel = PermissionLevel.Write) {
248
+ console.log('Temporarily granting access to member', member.id, 'for user', this.user.id);
249
+ addTemporaryMemberAccess(this.user.id, member.id, permissionLevel);
250
+ }
251
+
351
252
  /**
352
253
  * Only full admins can delete members permanently
353
254
  */
@@ -1249,6 +1150,10 @@ export class AdminPermissionChecker {
1249
1150
  return !!this.platformPermissions && !!this.platformPermissions.hasAccessRight(AccessRight.PlatformLoginAs);
1250
1151
  }
1251
1152
 
1153
+ canAccess(accessRight: AccessRight): boolean {
1154
+ return !!this.platformPermissions && !!this.platformPermissions.hasAccessRight(accessRight);
1155
+ }
1156
+
1252
1157
  hasPlatformFullAccess(): boolean {
1253
1158
  return !!this.platformPermissions && !!this.platformPermissions.hasFullAccess();
1254
1159
  }
@@ -1,6 +1,6 @@
1
1
  import { SimpleError } from '@simonbackx/simple-errors';
2
- import { Event, Group, Member, MemberPlatformMembership, MemberResponsibilityRecord, MemberWithRegistrations, Organization, OrganizationRegistrationPeriod, Payment, RegistrationPeriod, User, Webshop } from '@stamhoofd/models';
3
- import { Event as EventStruct, Group as GroupStruct, MemberPlatformMembership as MemberPlatformMembershipStruct, MemberWithRegistrationsBlob, MembersBlob, OrganizationRegistrationPeriod as OrganizationRegistrationPeriodStruct, Organization as OrganizationStruct, PaymentGeneral, PermissionLevel, PrivateWebshop, UserWithMembers, WebshopPreview, Webshop as WebshopStruct } from '@stamhoofd/structures';
2
+ import { CachedOutstandingBalance, Event, Group, Member, MemberPlatformMembership, MemberResponsibilityRecord, MemberWithRegistrations, Organization, OrganizationRegistrationPeriod, Payment, RegistrationPeriod, User, Webshop } from '@stamhoofd/models';
3
+ import { AccessRight, CachedOutstandingBalanceObject, CachedOutstandingBalanceObjectContact, CachedOutstandingBalance as CachedOutstandingBalanceStruct, CachedOutstandingBalanceType, Event as EventStruct, Group as GroupStruct, MemberPlatformMembership as MemberPlatformMembershipStruct, MemberWithRegistrationsBlob, MembersBlob, OrganizationRegistrationPeriod as OrganizationRegistrationPeriodStruct, Organization as OrganizationStruct, PaymentGeneral, PermissionLevel, PrivateWebshop, UserWithMembers, WebshopPreview, Webshop as WebshopStruct } from '@stamhoofd/structures';
4
4
 
5
5
  import { Formatter } from '@stamhoofd/utility';
6
6
  import { Context } from './Context';
@@ -422,4 +422,40 @@ export class AuthenticatedStructures {
422
422
 
423
423
  return result;
424
424
  }
425
+
426
+ static async cachedOutstandingBalances(balances: CachedOutstandingBalance[]): Promise<CachedOutstandingBalanceStruct[]> {
427
+ if (balances.length === 0) {
428
+ return [];
429
+ }
430
+
431
+ const organizationIds = Formatter.uniqueArray(balances.filter(b => b.objectType === CachedOutstandingBalanceType.organization).map(b => b.objectId));
432
+ const organizations = organizationIds.length > 0 ? await Organization.getByIDs(...organizationIds) : [];
433
+ const admins = await User.getAdmins(organizationIds, { verified: true });
434
+
435
+ const organizationStructs = await this.organizations(organizations);
436
+
437
+ const result: CachedOutstandingBalanceStruct[] = [];
438
+ for (const balance of balances) {
439
+ const organization = organizationStructs.find(o => o.id == balance.objectId) ?? null;
440
+ let thisAdmins: User[] = [];
441
+ if (organization) {
442
+ thisAdmins = admins.filter(a => a.permissions && a.permissions.forOrganization(organization)?.hasAccessRight(AccessRight.OrganizationFinanceDirector));
443
+ }
444
+
445
+ const struct = CachedOutstandingBalanceStruct.create({
446
+ ...balance,
447
+ object: CachedOutstandingBalanceObject.create({
448
+ name: organization?.name ?? 'Onbekend',
449
+ contacts: thisAdmins.map(a => CachedOutstandingBalanceObjectContact.create({
450
+ name: a.name ?? '',
451
+ emails: [a.email],
452
+ })),
453
+ }),
454
+ });
455
+
456
+ result.push(struct);
457
+ }
458
+
459
+ return result;
460
+ }
425
461
  }
@@ -0,0 +1,40 @@
1
+ import { getPermissionLevelNumber, PermissionLevel } from '@stamhoofd/structures';
2
+
3
+ const userMemberTemporaryAccessCache = new Map<string, { memberId: string; level: PermissionLevel; validUntil: Date }[]>();
4
+
5
+ export function hasTemporaryMemberAccess(userId: string, memberId, level: PermissionLevel) {
6
+ const list = userMemberTemporaryAccessCache.get(userId);
7
+ if (!list) {
8
+ return false;
9
+ }
10
+ const d = new Date();
11
+ return !!list.find(m =>
12
+ m.memberId === memberId
13
+ && getPermissionLevelNumber(m.level) >= getPermissionLevelNumber(level)
14
+ && m.validUntil > d,
15
+ );
16
+ }
17
+
18
+ export function addTemporaryMemberAccess(userId: string, memberId, level: PermissionLevel, timeout: number = 1000 * 60 * 60 * 24) {
19
+ deleteExpiredTemporaryMemberAccess();
20
+ let list = userMemberTemporaryAccessCache.get(userId);
21
+ if (!list) {
22
+ list = [];
23
+ userMemberTemporaryAccessCache.set(userId, list);
24
+ }
25
+ list.push({ memberId, level, validUntil: new Date(Date.now() + timeout) });
26
+ }
27
+
28
+ export function deleteExpiredTemporaryMemberAccess() {
29
+ const d = new Date();
30
+ for (const [userId, list] of userMemberTemporaryAccessCache) {
31
+ const filtered = list.filter(m => m.validUntil > d);
32
+
33
+ if (filtered.length === 0) {
34
+ userMemberTemporaryAccessCache.delete(userId);
35
+ }
36
+ else {
37
+ userMemberTemporaryAccessCache.set(userId, filtered);
38
+ }
39
+ }
40
+ }
@@ -38,7 +38,7 @@ export class ViesHelperStatic {
38
38
  patch.VATNumber = await ViesHelper.checkVATNumber(company.address.country, company.VATNumber);
39
39
 
40
40
  if (company.address.country === Country.Belgium) {
41
- patch.companyNumber = company.VATNumber;
41
+ patch.companyNumber = company.VATNumber.substring(2);
42
42
  }
43
43
  }
44
44
 
@@ -66,8 +66,14 @@ export class ViesHelperStatic {
66
66
  }
67
67
 
68
68
  // In Belgium, the company number syntax is the same as VAT number
69
+ let correctedVATNumber = companyNumber;
69
70
 
70
- const result = jsvat.checkVAT(companyNumber, [jsvat.belgium]);
71
+ if (companyNumber.length > 2 && companyNumber.substr(0, 2) !== country) {
72
+ // Add required country in VAT number
73
+ correctedVATNumber = country + companyNumber;
74
+ }
75
+
76
+ const result = jsvat.checkVAT(correctedVATNumber, [jsvat.belgium]);
71
77
 
72
78
  if (!result.isValid) {
73
79
  throw new SimpleError({
@@ -79,11 +85,11 @@ export class ViesHelperStatic {
79
85
 
80
86
  // If this is a valid VAT number, we can assume it's a valid company number
81
87
  try {
82
- const corrected = await this.checkVATNumber(Country.Belgium, companyNumber);
88
+ const corrected = await this.checkVATNumber(Country.Belgium, correctedVATNumber);
83
89
 
84
90
  // this is a VAT number, not a company number
85
91
  return {
86
- companyNumber: corrected,
92
+ companyNumber: corrected.substring(2),
87
93
  VATNumber: corrected,
88
94
  };
89
95
  }
@@ -98,7 +104,7 @@ export class ViesHelperStatic {
98
104
  }
99
105
 
100
106
  return {
101
- companyNumber: result.value ?? companyNumber,
107
+ companyNumber: result.value?.substring(2) ?? companyNumber,
102
108
 
103
109
  // VATNumber should always be set to null if it is not a valid VAT number
104
110
  VATNumber: null,
@@ -144,6 +150,12 @@ export class ViesHelperStatic {
144
150
  }
145
151
  // Unavailable: ignore for now
146
152
  console.error('VIES error', e);
153
+
154
+ throw new SimpleError({
155
+ code: 'service_unavailable',
156
+ message: 'De BTW-nummer validatie service (VIES) is tijdelijk niet beschikbaar. Probeer het later opnieuw.',
157
+ field: 'VATNumber',
158
+ });
147
159
  }
148
160
 
149
161
  return formatted;
@@ -0,0 +1,13 @@
1
+ import { SQLFilterDefinitions, baseSQLFilterCompilers, createSQLColumnFilterCompiler } from '@stamhoofd/sql';
2
+
3
+ /**
4
+ * Defines how to filter cached balance items in the database from StamhoofdFilter objects
5
+ */
6
+ export const cachedOutstandingBalanceFilterCompilers: SQLFilterDefinitions = {
7
+ ...baseSQLFilterCompilers,
8
+ id: createSQLColumnFilterCompiler('id'),
9
+ organizationId: createSQLColumnFilterCompiler('organizationId'),
10
+ objectType: createSQLColumnFilterCompiler('objectType'),
11
+ amount: createSQLColumnFilterCompiler('amount'),
12
+ amountPending: createSQLColumnFilterCompiler('amountPending'),
13
+ };
@@ -0,0 +1,46 @@
1
+ import { CachedOutstandingBalance } from '@stamhoofd/models';
2
+ import { SQL, SQLOrderBy, SQLOrderByDirection, SQLSortDefinitions } from '@stamhoofd/sql';
3
+
4
+ export const cachedOutstandingBalanceSorters: SQLSortDefinitions<CachedOutstandingBalance> = {
5
+ // WARNING! TEST NEW SORTERS THOROUGHLY!
6
+ // Try to avoid creating sorters on fields that er not 1:1 with the database, that often causes pagination issues if not thought through
7
+ // An example: sorting on 'name' is not a good idea, because it is a concatenation of two fields.
8
+ // You might be tempted to use ORDER BY firstName, lastName, but that will not work as expected and it needs to be ORDER BY CONCAT(firstName, ' ', lastName)
9
+ // Why? Because ORDER BY firstName, lastName produces a different order dan ORDER BY CONCAT(firstName, ' ', lastName) if there are multiple people with spaces in the first name
10
+ // And that again causes issues with pagination because the next query will append a filter of name > 'John Doe' - causing duplicate and/or skipped results
11
+ // What if you need mapping? simply map the sorters in the frontend: name -> firstname, lastname, age -> birthDay, etc.
12
+
13
+ id: {
14
+ getValue(a) {
15
+ return a.id;
16
+ },
17
+ toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
18
+ return new SQLOrderBy({
19
+ column: SQL.column('id'),
20
+ direction,
21
+ });
22
+ },
23
+ },
24
+ amount: {
25
+ getValue(a) {
26
+ return a.amount;
27
+ },
28
+ toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
29
+ return new SQLOrderBy({
30
+ column: SQL.column('amount'),
31
+ direction,
32
+ });
33
+ },
34
+ },
35
+ amountPending: {
36
+ getValue(a) {
37
+ return a.amountPending;
38
+ },
39
+ toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
40
+ return new SQLOrderBy({
41
+ column: SQL.column('amountPending'),
42
+ direction,
43
+ });
44
+ },
45
+ },
46
+ };