@stamhoofd/backend 2.1.2 → 2.2.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/.env.json CHANGED
@@ -23,14 +23,14 @@
23
23
  "demoApi": "api.stamhoofd",
24
24
  "rendererApi": "renderer.stamhoofd"
25
25
  },
26
- "translationNamespace": "stamhoofd",
26
+ "translationNamespace": "digit",
27
27
  "userMode": "organization",
28
28
 
29
29
  "PORT": 9091,
30
30
  "DB_HOST": "127.0.0.1",
31
31
  "DB_USER": "root",
32
32
  "DB_PASS": "root",
33
- "DB_DATABASE": "stamhoofd1",
33
+ "DB_DATABASE": "stamhoofd",
34
34
 
35
35
  "SMTP_HOST": "0.0.0.0",
36
36
  "SMTP_USERNAME": "username",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stamhoofd/backend",
3
- "version": "2.1.2",
3
+ "version": "2.2.0",
4
4
  "main": "./dist/index.js",
5
5
  "exports": {
6
6
  ".": {
@@ -50,5 +50,5 @@
50
50
  "postmark": "4.0.2",
51
51
  "stripe": "^11.5.0"
52
52
  },
53
- "gitHead": "2332fe7ce5922e32a7e9c5c947fdde572aadcc03"
53
+ "gitHead": "d67b2ac7c63880195902d29df682a2f2a709b82b"
54
54
  }
@@ -0,0 +1,47 @@
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 { GetInvoicesEndpoint } from './GetInvoicesEndpoint';
7
+
8
+ type Params = Record<string, never>;
9
+ type Query = CountFilteredRequest;
10
+ type Body = undefined;
11
+ type ResponseBody = CountResponse;
12
+
13
+ export class GetInvoicesCountEndpoint 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, "/admin/invoices/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.authenticate()
31
+
32
+ if (!Context.auth.hasPlatformFullAccess()) {
33
+ throw Context.auth.error()
34
+ }
35
+
36
+ const query = GetInvoicesEndpoint.buildQuery(request.query)
37
+
38
+ const count = await query
39
+ .count();
40
+
41
+ return new Response(
42
+ CountResponse.create({
43
+ count
44
+ })
45
+ );
46
+ }
47
+ }
@@ -0,0 +1,185 @@
1
+ /* eslint-disable @typescript-eslint/no-unsafe-argument */
2
+ import { Decoder } from '@simonbackx/simple-encoding';
3
+ import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
4
+ import { SimpleError } from '@simonbackx/simple-errors';
5
+ import { Organization, Payment, STInvoice } from '@stamhoofd/models';
6
+ import { SQL, SQLFilterDefinitions, SQLOrderBy, SQLOrderByDirection, SQLSortDefinitions, baseSQLFilterCompilers, compileToSQLFilter, compileToSQLSorter, createSQLExpressionFilterCompiler } from "@stamhoofd/sql";
7
+ import { CountFilteredRequest, LimitedFilteredRequest, PaginatedResponse, Payment as PaymentStruct, STInvoicePrivate, StamhoofdFilter, getSortFilter } from '@stamhoofd/structures';
8
+
9
+ import { Context } from '../../../helpers/Context';
10
+
11
+ type Params = Record<string, never>;
12
+ type Query = LimitedFilteredRequest;
13
+ type Body = undefined;
14
+ type ResponseBody = PaginatedResponse<STInvoicePrivate[], LimitedFilteredRequest>
15
+
16
+ export const filterCompilers: SQLFilterDefinitions = {
17
+ ...baseSQLFilterCompilers,
18
+ id: createSQLExpressionFilterCompiler(
19
+ SQL.column('stamhoofd_invoices', 'id')
20
+ ),
21
+ number: createSQLExpressionFilterCompiler(
22
+ SQL.column('stamhoofd_invoices', 'number')
23
+ )
24
+ }
25
+
26
+ const sorters: SQLSortDefinitions<STInvoice> = {
27
+ 'id': {
28
+ getValue(a) {
29
+ return a.id
30
+ },
31
+ toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
32
+ return new SQLOrderBy({
33
+ column: SQL.column('id'),
34
+ direction
35
+ })
36
+ }
37
+ },
38
+ 'number': {
39
+ getValue(a) {
40
+ return a.number
41
+ },
42
+ toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
43
+ return new SQLOrderBy({
44
+ column: SQL.column('number'),
45
+ direction
46
+ })
47
+ }
48
+ }
49
+ }
50
+
51
+ export class GetInvoicesEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
52
+ queryDecoder = LimitedFilteredRequest as Decoder<LimitedFilteredRequest>
53
+
54
+ protected doesMatch(request: Request): [true, Params] | [false] {
55
+ if (request.method != "GET") {
56
+ return [false];
57
+ }
58
+
59
+ const params = Endpoint.parseParameters(request.url, "/admin/invoices", {});
60
+
61
+ if (params) {
62
+ return [true, params as Params];
63
+ }
64
+ return [false];
65
+ }
66
+
67
+ static buildQuery(q: CountFilteredRequest|LimitedFilteredRequest) {
68
+ const query = SQL
69
+ .select(
70
+ SQL.wildcard('stamhoofd_invoices')
71
+ )
72
+ .from(
73
+ SQL.table('stamhoofd_invoices')
74
+ )
75
+ .whereNot(SQL.column('stamhoofd_invoices', 'number'), null);
76
+
77
+
78
+ if (q.filter) {
79
+ query.where(compileToSQLFilter(q.filter, filterCompilers))
80
+ }
81
+
82
+ if (q.search) {
83
+ let searchFilter: StamhoofdFilter|null = null
84
+
85
+ // todo: auto detect e-mailaddresses and search on admins
86
+ searchFilter = {
87
+ name: {
88
+ $contains: q.search
89
+ }
90
+ }
91
+
92
+ if (searchFilter) {
93
+ query.where(compileToSQLFilter(searchFilter, filterCompilers))
94
+ }
95
+ }
96
+
97
+ if (q instanceof LimitedFilteredRequest) {
98
+ if (q.pageFilter) {
99
+ query.where(compileToSQLFilter(q.pageFilter, filterCompilers))
100
+ }
101
+
102
+ query.orderBy(compileToSQLSorter(q.sort, sorters))
103
+ query.limit(q.limit)
104
+ }
105
+
106
+ return query
107
+ }
108
+
109
+ async handle(request: DecodedRequest<Params, Query, Body>) {
110
+ await Context.authenticate()
111
+
112
+ if (!Context.auth.hasPlatformFullAccess()) {
113
+ throw Context.auth.error()
114
+ }
115
+
116
+ const maxLimit = Context.auth.hasSomePlatformAccess() ? 1000 : 100;
117
+
118
+ if (request.query.limit > maxLimit) {
119
+ throw new SimpleError({
120
+ code: 'invalid_field',
121
+ field: 'limit',
122
+ message: 'Limit can not be more than ' + maxLimit
123
+ })
124
+ }
125
+
126
+ if (request.query.limit < 1) {
127
+ throw new SimpleError({
128
+ code: 'invalid_field',
129
+ field: 'limit',
130
+ message: 'Limit can not be less than 1'
131
+ })
132
+ }
133
+
134
+ const data = await GetInvoicesEndpoint.buildQuery(request.query).fetch()
135
+ const invoices = STInvoice.fromRows(data, 'stamhoofd_invoices');
136
+
137
+ let next: LimitedFilteredRequest|undefined;
138
+
139
+ if (invoices.length >= request.query.limit) {
140
+ const lastObject = invoices[invoices.length - 1];
141
+ const nextFilter = getSortFilter(lastObject, sorters, request.query.sort);
142
+
143
+ next = new LimitedFilteredRequest({
144
+ filter: request.query.filter,
145
+ pageFilter: nextFilter,
146
+ sort: request.query.sort,
147
+ limit: request.query.limit,
148
+ search: request.query.search
149
+ })
150
+
151
+ if (JSON.stringify(nextFilter) === JSON.stringify(request.query.pageFilter)) {
152
+ console.error('Found infinite loading loop for', request.query);
153
+ next = undefined;
154
+ }
155
+ }
156
+
157
+ // Get payments + organizations
158
+ const paymentIds = invoices.flatMap(i => i.paymentId ? [i.paymentId] : [])
159
+ const organizationIds = invoices.flatMap(i => i.organizationId ? [i.organizationId] : [])
160
+
161
+ const payments = await Payment.getByIDs(...paymentIds)
162
+ const organizations = await Organization.getByIDs(...organizationIds)
163
+
164
+ const structures: STInvoicePrivate[] = []
165
+ for (const invoice of invoices) {
166
+ const payment = payments.find(p => p.id === invoice.paymentId)
167
+ const organization = organizations.find(p => p.id === invoice.organizationId)
168
+ structures.push(
169
+ STInvoicePrivate.create({
170
+ ...invoice,
171
+ payment: payment ? PaymentStruct.create(payment) : null,
172
+ organization: organization ? (await organization.getStructure({emptyGroups: true})) : undefined,
173
+ settlement: payment?.settlement ?? null,
174
+ })
175
+ )
176
+ }
177
+
178
+ return new Response(
179
+ new PaginatedResponse<STInvoicePrivate[], LimitedFilteredRequest>({
180
+ results: structures,
181
+ next
182
+ })
183
+ );
184
+ }
185
+ }
@@ -0,0 +1,88 @@
1
+ import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
2
+ import { SQL, SQLAlias, SQLSum, SQLCount, SQLDistinct, SQLSelectAs } from '@stamhoofd/sql';
3
+ import { ChargeMembershipsSummary } from '@stamhoofd/structures';
4
+ import { Context } from '../../../helpers/Context';
5
+
6
+
7
+ type Params = Record<string, never>;
8
+ type Query = Record<string, never>;
9
+ type Body = undefined;
10
+ type ResponseBody = ChargeMembershipsSummary;
11
+
12
+ export class GetChargeMembershipsSummaryEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
13
+ protected doesMatch(request: Request): [true, Params] | [false] {
14
+ if (request.method != "GET") {
15
+ return [false];
16
+ }
17
+
18
+ const params = Endpoint.parseParameters(request.url, "/admin/charge-memberships/summary", {});
19
+
20
+ if (params) {
21
+ return [true, params as Params];
22
+ }
23
+ return [false];
24
+ }
25
+
26
+ async handle(request: DecodedRequest<Params, Query, Body>) {
27
+ await Context.authenticate()
28
+
29
+ if (!Context.auth.hasPlatformFullAccess()) {
30
+ throw Context.auth.error()
31
+ }
32
+
33
+ const query = SQL
34
+ .select(
35
+ new SQLSelectAs(
36
+ new SQLCount(
37
+ new SQLDistinct(
38
+ SQL.column('member_platform_memberships', 'id')
39
+ )
40
+ ),
41
+ new SQLAlias('data__memberships')
42
+ ),
43
+ new SQLSelectAs(
44
+ new SQLCount(
45
+ new SQLDistinct(
46
+ SQL.column('member_platform_memberships', 'memberId')
47
+ )
48
+ ),
49
+ new SQLAlias('data__members')
50
+ ),
51
+ new SQLSelectAs(
52
+ new SQLCount(
53
+ new SQLDistinct(
54
+ SQL.column('member_platform_memberships', 'organizationId')
55
+ )
56
+ ),
57
+ new SQLAlias('data__organizations')
58
+ ),
59
+ new SQLSelectAs(
60
+ new SQLSum(
61
+ SQL.column('member_platform_memberships', 'price')
62
+ ),
63
+ new SQLAlias('data__price')
64
+ )
65
+ )
66
+ .from(
67
+ SQL.table('member_platform_memberships')
68
+ )
69
+ .where(SQL.column('invoiceId'), null)
70
+ .andWhere(SQL.column('invoiceItemDetailId'), null);
71
+
72
+
73
+ const result = await query.fetch();
74
+ const members = result[0]['data']['members'] as number;
75
+ const memberships = result[0]['data']['memberships'] as number;
76
+ const organizations = result[0]['data']['organizations'] as number;
77
+ const price = result[0]['data']['price'] as number;
78
+
79
+ return new Response(
80
+ ChargeMembershipsSummary.create({
81
+ memberships: memberships ?? 0,
82
+ members: members ?? 0,
83
+ price: price ?? 0,
84
+ organizations: organizations ?? 0
85
+ })
86
+ );
87
+ }
88
+ }
@@ -271,11 +271,13 @@ export class GetOrganizationsEndpoint extends Endpoint<Params, Query, Body, Resp
271
271
  async handle(request: DecodedRequest<Params, Query, Body>) {
272
272
  await Context.authenticate()
273
273
 
274
- if (request.query.limit > 100) {
274
+ const maxLimit = Context.auth.hasSomePlatformAccess() ? 1000 : 100;
275
+
276
+ if (request.query.limit > maxLimit) {
275
277
  throw new SimpleError({
276
278
  code: 'invalid_field',
277
279
  field: 'limit',
278
- message: 'Limit can not be more than 100'
280
+ message: 'Limit can not be more than ' + maxLimit
279
281
  })
280
282
  }
281
283
 
@@ -48,6 +48,7 @@ const registrationFilterCompilers: SQLFilterDefinitions = {
48
48
  status: createSQLExpressionFilterCompiler(
49
49
  SQL.column('groups', 'status')
50
50
  ),
51
+ defaultAgeGroupId: createSQLColumnFilterCompiler(SQL.column('groups', 'defaultAgeGroupId')),
51
52
  })
52
53
  }
53
54
 
@@ -125,6 +126,49 @@ const filterCompilers: SQLFilterDefinitions = {
125
126
  }
126
127
  ),
127
128
 
129
+ responsibilities: createSQLRelationFilterCompiler(
130
+ SQL.select()
131
+ .from(
132
+ SQL.table('member_responsibility_records')
133
+ )
134
+ .where(
135
+ SQL.column('memberId'),
136
+ SQL.column('members', 'id'),
137
+ ),
138
+ {
139
+ ...baseSQLFilterCompilers,
140
+ // Alias for responsibilityId
141
+ "id": createSQLColumnFilterCompiler(SQL.column('member_responsibility_records', 'responsibilityId')),
142
+ "responsibilityId": createSQLColumnFilterCompiler(SQL.column('member_responsibility_records', 'responsibilityId')),
143
+ "organizationId": createSQLColumnFilterCompiler(SQL.column('member_responsibility_records', 'organizationId')),
144
+ "startDate": createSQLColumnFilterCompiler(SQL.column('member_responsibility_records', 'startDate')),
145
+ "endDate": createSQLColumnFilterCompiler(SQL.column('member_responsibility_records', 'endDate')),
146
+ }
147
+ ),
148
+
149
+ platformMemberships: createSQLRelationFilterCompiler(
150
+ SQL.select()
151
+ .from(
152
+ SQL.table('member_platform_memberships')
153
+ )
154
+ .where(
155
+ SQL.column('memberId'),
156
+ SQL.column('members', 'id'),
157
+ ),
158
+ {
159
+ ...baseSQLFilterCompilers,
160
+ "id": createSQLColumnFilterCompiler(SQL.column('member_platform_memberships', 'id')),
161
+ "membershipTypeId": createSQLColumnFilterCompiler(SQL.column('member_platform_memberships', 'membershipTypeId')),
162
+ "organizationId": createSQLColumnFilterCompiler(SQL.column('member_platform_memberships', 'organizationId')),
163
+ "periodId": createSQLColumnFilterCompiler(SQL.column('member_platform_memberships', 'periodId')),
164
+ "price": createSQLColumnFilterCompiler(SQL.column('member_platform_memberships', 'price')),
165
+ "invoiceId": createSQLColumnFilterCompiler(SQL.column('member_platform_memberships', 'invoiceId')),
166
+ "startDate": createSQLColumnFilterCompiler(SQL.column('member_platform_memberships', 'startDate')),
167
+ "endDate": createSQLColumnFilterCompiler(SQL.column('member_platform_memberships', 'endDate')),
168
+ "expireDate": createSQLColumnFilterCompiler(SQL.column('member_platform_memberships', 'expireDate')),
169
+ }
170
+ ),
171
+
128
172
  /**
129
173
  * @deprecated?
130
174
  */
@@ -270,6 +314,11 @@ export class GetMembersEndpoint extends Endpoint<Params, Query, Body, ResponseBo
270
314
  periodId: platform.periodId,
271
315
  registeredAt: {
272
316
  $neq: null
317
+ },
318
+ group: {
319
+ defaultAgeGroupId: {
320
+ $neq: null
321
+ }
273
322
  }
274
323
  }
275
324
  }
@@ -363,11 +412,13 @@ export class GetMembersEndpoint extends Endpoint<Params, Query, Body, ResponseBo
363
412
  await Context.setOptionalOrganizationScope();
364
413
  await Context.authenticate()
365
414
 
366
- if (request.query.limit > 100) {
415
+ const maxLimit = Context.auth.hasSomePlatformAccess() ? 1000 : 100;
416
+
417
+ if (request.query.limit > maxLimit) {
367
418
  throw new SimpleError({
368
419
  code: 'invalid_field',
369
420
  field: 'limit',
370
- message: 'Limit can not be more than 100'
421
+ message: 'Limit can not be more than ' + maxLimit
371
422
  })
372
423
  }
373
424
 
@@ -2,8 +2,8 @@ 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, BalanceItemPayment, Document, Group, Member, MemberFactory, MemberResponsibilityRecord, MemberWithRegistrations, Organization, Payment, Platform, Registration, User } from '@stamhoofd/models';
6
- import { BalanceItemStatus, MemberWithRegistrationsBlob, MembersBlob, PaymentMethod, PaymentStatus, PermissionLevel, Registration as RegistrationStruct, User as UserStruct } from "@stamhoofd/structures";
5
+ import { BalanceItem, MemberPlatformMembership, BalanceItemPayment, Document, Group, Member, MemberFactory, MemberResponsibilityRecord, MemberWithRegistrations, Organization, Payment, Platform, Registration, RegistrationPeriod, User } from '@stamhoofd/models';
6
+ import { BalanceItemStatus, MemberPlatformMembership as MemberPlatformMembershipStruct, MemberWithRegistrationsBlob, MembersBlob, PaymentMethod, PaymentStatus, PermissionLevel, Registration as RegistrationStruct, User as UserStruct } from "@stamhoofd/structures";
7
7
  import { Formatter } from '@stamhoofd/utility';
8
8
 
9
9
  import { AuthenticatedStructures } from '../../../helpers/AuthenticatedStructures';
@@ -52,6 +52,8 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
52
52
 
53
53
  const members: MemberWithRegistrations[] = []
54
54
 
55
+ const platform = await Platform.getShared()
56
+
55
57
  // Cache
56
58
  const groups: Group[] = []
57
59
 
@@ -71,6 +73,7 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
71
73
 
72
74
  const balanceItemMemberIds: string[] = []
73
75
  const balanceItemRegistrationIdsPerOrganization: Map<string, string[]> = new Map()
76
+ const updateMembershipMemberIds = new Set<string>()
74
77
 
75
78
  function addBalanceItemRegistrationId(organizationId: string, registrationId: string) {
76
79
  const existing = balanceItemRegistrationIdsPerOrganization.get(organizationId);
@@ -121,6 +124,14 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
121
124
  throw Context.auth.notFoundOrNoAccess("Je hebt niet voldoende rechten om leden toe te voegen in deze groep")
122
125
  }
123
126
 
127
+ const period = await RegistrationPeriod.getByID(group.periodId)
128
+ if (!period || period.locked) {
129
+ throw new SimpleError({
130
+ code: "period_locked",
131
+ message: "Deze inschrijvingsperiode is afgesloten en staat geen wijzigingen meer toe.",
132
+ })
133
+ }
134
+
124
135
  // Set organization id of member based on registrations
125
136
  if (!organization && STAMHOOFD.userMode !== 'platform' && !member.organizationId) {
126
137
  member.organizationId = group.organizationId
@@ -161,6 +172,7 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
161
172
  await member.save()
162
173
  members.push(member)
163
174
  balanceItemMemberIds.push(member.id)
175
+ updateMembershipMemberIds.add(member.id)
164
176
 
165
177
  // Add registrations
166
178
  for (const registrationStruct of struct.registrations) {
@@ -224,7 +236,12 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
224
236
  }
225
237
 
226
238
  let group: Group | null = null
227
-
239
+
240
+ console.log('Patch registration', patchRegistration)
241
+
242
+ if (patchRegistration.group) {
243
+ patchRegistration.groupId = patchRegistration.group.id
244
+ }
228
245
 
229
246
  if (patchRegistration.groupId) {
230
247
  group = await getGroup(patchRegistration.groupId)
@@ -263,6 +280,14 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
263
280
  })
264
281
  }
265
282
 
283
+ const period = await RegistrationPeriod.getByID(group.periodId)
284
+ if (!period || period.locked) {
285
+ throw new SimpleError({
286
+ code: "period_locked",
287
+ message: "Deze inschrijvingsperiode is afgesloten en staat geen wijzigingen meer toe.",
288
+ })
289
+ }
290
+
266
291
  // TODO: allow group changes
267
292
  registration.waitingList = patchRegistration.waitingList ?? registration.waitingList
268
293
 
@@ -275,6 +300,7 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
275
300
  }
276
301
  registration.cycle = patchRegistration.cycle ?? registration.cycle
277
302
  registration.groupId = patchRegistration.groupId ?? registration.groupId
303
+ registration.group = group
278
304
  registration.organizationId = patchRegistration.organizationId ?? registration.organizationId
279
305
 
280
306
  // Check if we should create a placeholder payment?
@@ -322,6 +348,7 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
322
348
  }
323
349
 
324
350
  await registration.save()
351
+ updateMembershipMemberIds.add(member.id)
325
352
  }
326
353
 
327
354
  for (const deleteId of patch.registrations.getDeletes()) {
@@ -337,13 +364,21 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
337
364
  if (!await Context.auth.canAccessRegistration(registration, PermissionLevel.Write)) {
338
365
  throw Context.auth.error("Je hebt niet voldoende rechten om deze inschrijving te verwijderen")
339
366
  }
367
+ const oldGroup = await getGroup(registration.groupId)
368
+ const period = oldGroup && await RegistrationPeriod.getByID(oldGroup.periodId)
369
+ if (!period || period.locked) {
370
+ throw new SimpleError({
371
+ code: "period_locked",
372
+ message: "Deze inschrijvingsperiode is afgesloten en staat geen wijzigingen meer toe.",
373
+ })
374
+ }
340
375
 
341
- balanceItemMemberIds.push(member.id)
376
+ balanceItemMemberIds.push(member.id)
377
+ updateMembershipMemberIds.add(member.id)
342
378
  await BalanceItem.deleteForDeletedRegistration(registration.id)
343
379
  await registration.delete()
344
380
  member.registrations = member.registrations.filter(r => r.id !== deleteId)
345
381
 
346
- const oldGroup = await getGroup(registration.groupId)
347
382
  if (oldGroup) {
348
383
  // We need to update this group occupancy because we moved one member away from it
349
384
  updateGroups.set(oldGroup.id, oldGroup)
@@ -358,9 +393,17 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
358
393
  if (!group || group.organizationId !== struct.organizationId || !await Context.auth.canAccessGroup(group, PermissionLevel.Write)) {
359
394
  throw Context.auth.error("Je hebt niet voldoende rechten om inschrijvingen in deze groep te maken")
360
395
  }
396
+ const period = await RegistrationPeriod.getByID(group.periodId)
397
+ if (!period || period.locked) {
398
+ throw new SimpleError({
399
+ code: "period_locked",
400
+ message: "Deze inschrijvingsperiode is afgesloten en staat geen wijzigingen meer toe.",
401
+ })
402
+ }
361
403
 
362
404
  const reg = await this.addRegistration(member, struct, group)
363
405
  balanceItemMemberIds.push(member.id)
406
+ updateMembershipMemberIds.add(member.id)
364
407
  addBalanceItemRegistrationId(reg.organizationId, reg.id)
365
408
 
366
409
  // We need to update this group occupancy because we moved one member away from it
@@ -476,6 +519,97 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
476
519
  await PatchOrganizationMembersEndpoint.updateManagers(member)
477
520
  }
478
521
 
522
+ // Add platform memberships
523
+ for (const {put} of patch.platformMemberships.getPuts()) {
524
+ if (put.periodId !== platform.periodId) {
525
+ throw new SimpleError({
526
+ code: "invalid_field",
527
+ message: "Invalid period",
528
+ human: "Je kan geen aansluitingen maken voor een andere werkjaar dan het actieve werkjaar",
529
+ field: "periodId"
530
+ })
531
+ }
532
+
533
+ if (organization && put.organizationId !== organization.id) {
534
+ throw new SimpleError({
535
+ code: "invalid_field",
536
+ message: "Invalid organization",
537
+ human: "Je kan geen aansluitingen maken voor een andere vereniging",
538
+ field: "organizationId"
539
+ })
540
+ }
541
+
542
+ if (!await Context.auth.hasFullAccess(put.organizationId)) {
543
+ throw Context.auth.error("Je hebt niet voldoende rechten om deze aansluiting toe te voegen")
544
+ }
545
+
546
+ if (!platform.config.membershipTypes.find(t => t.id === put.membershipTypeId)) {
547
+ throw new SimpleError({
548
+ code: "invalid_field",
549
+ field: "membershipTypeId",
550
+ message: "Invalid membership type",
551
+ human: "Dit aansluitingstype bestaat niet"
552
+ })
553
+ }
554
+
555
+ // Check duplicate memberships
556
+
557
+ // Check dates
558
+
559
+ // Calculate prices
560
+
561
+ const membership = new MemberPlatformMembership()
562
+ membership.id = put.id
563
+ membership.memberId = member.id
564
+ membership.membershipTypeId = put.membershipTypeId
565
+ membership.organizationId = put.organizationId
566
+ membership.periodId = put.periodId
567
+
568
+ membership.startDate = put.startDate
569
+ membership.endDate = put.endDate
570
+ membership.expireDate = put.expireDate
571
+
572
+ await membership.calculatePrice()
573
+ await membership.save()
574
+ }
575
+
576
+ // Delete platform memberships
577
+ for (const id of patch.platformMemberships.getDeletes()) {
578
+ const membership = await MemberPlatformMembership.getByID(id)
579
+
580
+ if (!membership || membership.memberId !== member.id) {
581
+ throw new SimpleError({
582
+ code: "invalid_field",
583
+ field: "id",
584
+ message: "Invalid id",
585
+ human: "Deze aansluiting bestaat niet"
586
+ })
587
+ }
588
+
589
+ if (!await Context.auth.hasFullAccess(membership.organizationId)) {
590
+ throw Context.auth.error("Je hebt niet voldoende rechten om deze aansluiting te verwijderen")
591
+ }
592
+
593
+ if (membership.periodId !== platform.periodId) {
594
+ throw new SimpleError({
595
+ code: "invalid_field",
596
+ message: "Invalid period",
597
+ human: "Je kan geen aansluitingen meer verwijderen voor een ander werkjaar dan het actieve werkjaar",
598
+ field: "periodId"
599
+ })
600
+ }
601
+
602
+ if (membership.invoiceId || membership.invoiceItemDetailId) {
603
+ throw new SimpleError({
604
+ code: "invalid_field",
605
+ message: "Invalid invoice",
606
+ human: "Je kan geen aansluiting verwijderen die al werd gefactureerd",
607
+ })
608
+ }
609
+
610
+ await membership.delete()
611
+ }
612
+
479
613
  if (!members.find(m => m.id === member.id)) {
480
614
  members.push(member)
481
615
  }
@@ -523,6 +657,12 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
523
657
  }
524
658
  }
525
659
 
660
+ for (const member of members) {
661
+ if (updateMembershipMemberIds.has(member.id)) {
662
+ await member.updateMemberships()
663
+ }
664
+ }
665
+
526
666
  return new Response(
527
667
  await AuthenticatedStructures.membersBlob(members)
528
668
  );
@@ -1,9 +1,10 @@
1
1
  import { AutoEncoderPatchType, Decoder, patchObject } from "@simonbackx/simple-encoding";
2
2
  import { DecodedRequest, Endpoint, Request, Response } from "@simonbackx/simple-endpoints";
3
- import { Platform } from "@stamhoofd/models";
3
+ import { Platform, RegistrationPeriod } from "@stamhoofd/models";
4
4
  import { Platform as PlatformStruct } from "@stamhoofd/structures";
5
5
 
6
6
  import { Context } from "../../../helpers/Context";
7
+ import { SimpleError } from "@simonbackx/simple-errors";
7
8
 
8
9
  type Params = Record<string, never>;
9
10
  type Query = undefined;
@@ -57,6 +58,17 @@ export class PatchPlatformEndpoint extends Endpoint<Params, Query, Body, Respons
57
58
  platform.config = patchObject(platform.config, request.body.config)
58
59
  }
59
60
 
61
+ if (request.body.period && request.body.period.id !== platform.periodId) {
62
+ const period = await RegistrationPeriod.getByID(request.body.period.id)
63
+ if (!period || period.organizationId) {
64
+ throw new SimpleError({
65
+ code: "invalid_period",
66
+ message: "Invalid period"
67
+ })
68
+ }
69
+ platform.periodId = period.id
70
+ }
71
+
60
72
  await platform.save()
61
73
  return new Response(await Platform.getSharedPrivateStruct());
62
74
  }
@@ -200,11 +200,17 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
200
200
 
201
201
  if (item.waitingList) {
202
202
  registration.waitingList = true
203
+ registration.canRegister = false
203
204
  registration.reservedUntil = null
204
205
  await registration.save()
205
206
  } else {
206
- registration.waitingList = false
207
- registration.canRegister = false
207
+ if (registration.waitingList && registration.canRegister) {
208
+ // Keep data: otherwise people cannot retry if the payment fails
209
+ // We'll mark the registration as valid after the payment
210
+ } else {
211
+ registration.waitingList = false
212
+ registration.canRegister = false
213
+ }
208
214
  registration.price = item.calculatedPrice
209
215
  payRegistrations.push({
210
216
  registration,
@@ -286,6 +292,7 @@ export class RegisterMembersEndpoint extends Endpoint<Params, Query, Body, Respo
286
292
  // registration.paymentId = payment.id
287
293
 
288
294
  registration.reservedUntil = null
295
+ registration.canRegister = false
289
296
 
290
297
  if (payment.method == PaymentMethod.Transfer || payment.method == PaymentMethod.PointOfSale || payment.status == PaymentStatus.Succeeded) {
291
298
  await registration.markValid()
@@ -4,7 +4,7 @@ import { RegistrationPeriod as RegistrationPeriodStruct } from "@stamhoofd/struc
4
4
 
5
5
  import { AuthenticatedStructures } from '../../../helpers/AuthenticatedStructures';
6
6
  import { Context } from '../../../helpers/Context';
7
- import { RegistrationPeriod } from '@stamhoofd/models';
7
+ import { Platform, RegistrationPeriod } from '@stamhoofd/models';
8
8
  import { SimpleError } from '@simonbackx/simple-errors';
9
9
 
10
10
  type Params = Record<string, never>;
@@ -107,6 +107,9 @@ export class PatchRegistrationPeriodsEndpoint extends Endpoint<Params, Query, Bo
107
107
  await model.delete();
108
108
  }
109
109
 
110
+ // Clear platform cache
111
+ Platform.clearCache()
112
+
110
113
  return new Response(
111
114
  periods.map(p => p.getStructure())
112
115
  );
@@ -1,8 +1,8 @@
1
1
  import { AutoEncoderPatchType, Decoder, ObjectData, patchObject } from '@simonbackx/simple-encoding';
2
2
  import { DecodedRequest, Endpoint, Request, Response } from "@simonbackx/simple-endpoints";
3
3
  import { SimpleError, SimpleErrors } from '@simonbackx/simple-errors';
4
- import { Group, Organization,PayconiqPayment, Platform, StripeAccount, Token, User, Webshop } from '@stamhoofd/models';
5
- import { BuckarooSettings, GroupPrivateSettings, Organization as OrganizationStruct, OrganizationPatch, PayconiqAccount, PaymentMethod, PaymentMethodHelper, PermissionLevel, Permissions, PermissionsResourceType,ResourcePermissions, UserPermissions, Version, OrganizationMetaData } from "@stamhoofd/structures";
4
+ import { Organization, OrganizationRegistrationPeriod, PayconiqPayment, Platform, RegistrationPeriod, StripeAccount, User, Webshop } from '@stamhoofd/models';
5
+ import { BuckarooSettings, OrganizationMetaData, OrganizationPatch, Organization as OrganizationStruct, PayconiqAccount, PaymentMethod, PaymentMethodHelper, PermissionLevel, UserPermissions } from "@stamhoofd/structures";
6
6
  import { Formatter } from '@stamhoofd/utility';
7
7
 
8
8
  import { AuthenticatedStructures } from '../../../../helpers/AuthenticatedStructures';
@@ -297,6 +297,36 @@ export class PatchOrganizationEndpoint extends Endpoint<Params, Query, Body, Res
297
297
  organization.uri = request.body.uri
298
298
  }
299
299
 
300
+ if (request.body.period && request.body.period.id !== organization.periodId) {
301
+ const organizationPeriod = await OrganizationRegistrationPeriod.getByID(request.body.period.id)
302
+ if (!organizationPeriod || organizationPeriod.organizationId !== organization.id) {
303
+ throw new SimpleError({
304
+ code: "invalid_field",
305
+ message: "De periode die je wilt instellen bestaat niet (meer)",
306
+ field: "period"
307
+ })
308
+ }
309
+
310
+ const period = await RegistrationPeriod.getByID(organizationPeriod.periodId)
311
+ if (!period || (period.organizationId && period.organizationId !== organization.id)) {
312
+ throw new SimpleError({
313
+ code: "invalid_field",
314
+ message: "De periode die je wilt instellen bestaat niet (meer)",
315
+ field: "period"
316
+ })
317
+ }
318
+
319
+ if (period.locked) {
320
+ throw new SimpleError({
321
+ code: "invalid_field",
322
+ message: "De periode die je wilt instellen is reeds afgesloten",
323
+ field: "period"
324
+ })
325
+ }
326
+
327
+ organization.periodId = period.id
328
+ }
329
+
300
330
  // Save the organization
301
331
  await organization.save()
302
332
  } else {
@@ -3,7 +3,7 @@ import { GroupPrivateSettings, OrganizationRegistrationPeriod as OrganizationReg
3
3
 
4
4
  import { AutoEncoderPatchType, Decoder, PatchableArrayAutoEncoder, PatchableArrayDecoder, StringDecoder } from "@simonbackx/simple-encoding";
5
5
  import { Context } from "../../../../helpers/Context";
6
- import { Group, OrganizationRegistrationPeriod, RegistrationPeriod } from "@stamhoofd/models";
6
+ import { Group, OrganizationRegistrationPeriod, Platform, RegistrationPeriod } from "@stamhoofd/models";
7
7
  import { SimpleError } from "@simonbackx/simple-errors";
8
8
 
9
9
  type Params = Record<string, never>;
@@ -37,10 +37,70 @@ export class PatchRegistrationPeriodsEndpoint extends Endpoint<Params, Query, Bo
37
37
 
38
38
  if (!await Context.auth.hasFullAccess(organization.id)) {
39
39
  throw Context.auth.error()
40
- }
40
+ }
41
+
42
+ const platform = await Platform.getShared()
43
+
44
+ function validateDefaultGroupId(id: string|null): string|null {
45
+ if (id === null) {
46
+ return id;
47
+ }
48
+
49
+ if (platform.config.defaultAgeGroups.find(g => g.id === id)) {
50
+ return id;
51
+ }
52
+
53
+ throw new SimpleError({
54
+ code: "invalid_default_age_group",
55
+ message: "Invalid default age group",
56
+ human: "De standaard leeftijdsgroep is ongeldig",
57
+ statusCode: 400
58
+ })
59
+ }
41
60
 
42
61
  const structs: OrganizationRegistrationPeriodStruct[] = [];
43
62
 
63
+ for (const {put} of request.body.getPuts()) {
64
+ if (!await Context.auth.hasFullAccess(organization.id)) {
65
+ throw Context.auth.error()
66
+ }
67
+ const period = await RegistrationPeriod.getByID(put.period.id);
68
+
69
+ if (!period) {
70
+ throw new SimpleError({
71
+ code: "not_found",
72
+ message: "Period not found",
73
+ statusCode: 404
74
+ })
75
+ }
76
+
77
+ const organizationPeriod = new OrganizationRegistrationPeriod();
78
+ organizationPeriod.id = put.id;
79
+ organizationPeriod.organizationId = organization.id;
80
+ organizationPeriod.periodId = put.period.id;
81
+ organizationPeriod.settings = put.settings;
82
+ await organizationPeriod.save();
83
+
84
+ for (const struct of put.groups) {
85
+ const model = new Group()
86
+ model.id = struct.id
87
+ model.organizationId = organization.id
88
+ model.defaultAgeGroupId = validateDefaultGroupId(struct.defaultAgeGroupId)
89
+ model.periodId = organizationPeriod.periodId
90
+ model.settings = struct.settings
91
+ model.privateSettings = struct.privateSettings ?? GroupPrivateSettings.create({})
92
+ model.status = struct.status
93
+ await model.updateOccupancy()
94
+ await model.save();
95
+ }
96
+ const groups = await Group.getAll(organization.id, organizationPeriod.periodId)
97
+
98
+ // Delete unreachable categories first
99
+ await organizationPeriod.cleanCategories(groups);
100
+ await Group.deleteUnreachable(organization.id, organizationPeriod, groups)
101
+ structs.push(organizationPeriod.getStructure(period, groups));
102
+ }
103
+
44
104
  for (const patch of request.body.getPatches()) {
45
105
  const organizationPeriod = await OrganizationRegistrationPeriod.getByID(patch.id);
46
106
  if (!organizationPeriod || organizationPeriod.organizationId !== organization.id) {
@@ -112,6 +172,7 @@ export class PatchRegistrationPeriodsEndpoint extends Endpoint<Params, Query, Bo
112
172
  const model = new Group()
113
173
  model.id = struct.id
114
174
  model.organizationId = organization.id
175
+ model.defaultAgeGroupId = validateDefaultGroupId(struct.defaultAgeGroupId)
115
176
  model.periodId = organizationPeriod.periodId
116
177
  model.settings = struct.settings
117
178
  model.privateSettings = struct.privateSettings ?? GroupPrivateSettings.create({})
@@ -181,6 +242,10 @@ export class PatchRegistrationPeriodsEndpoint extends Endpoint<Params, Query, Bo
181
242
  if (struct.deletedAt !== undefined) {
182
243
  model.deletedAt = struct.deletedAt
183
244
  }
245
+
246
+ if (struct.defaultAgeGroupId !== undefined) {
247
+ model.defaultAgeGroupId = validateDefaultGroupId(struct.defaultAgeGroupId)
248
+ }
184
249
 
185
250
  await model.updateOccupancy()
186
251
  await model.save();
@@ -191,15 +256,13 @@ export class PatchRegistrationPeriodsEndpoint extends Endpoint<Params, Query, Bo
191
256
 
192
257
  if (deleteUnreachable) {
193
258
  // Delete unreachable categories first
194
- await organization.cleanCategories(groups);
259
+ await organizationPeriod.cleanCategories(groups);
195
260
  await Group.deleteUnreachable(organization.id, organizationPeriod, groups)
196
261
  }
197
262
 
198
263
  if (period) {
199
-
200
264
  structs.push(organizationPeriod.getStructure(period, groups));
201
265
  }
202
-
203
266
  }
204
267
 
205
268
  return new Response(
@@ -1,6 +1,6 @@
1
1
  import { SimpleError } from "@simonbackx/simple-errors";
2
- import { Group, MemberResponsibilityRecord, MemberWithRegistrations, Organization, OrganizationRegistrationPeriod, Payment, RegistrationPeriod, User, Webshop } from "@stamhoofd/models";
3
- import { OrganizationRegistrationPeriod as OrganizationRegistrationPeriodStruct, MemberResponsibilityRecord as MemberResponsibilityRecordStruct, User as UserStruct, Group as GroupStruct, MembersBlob, Organization as OrganizationStruct, PaymentGeneral, PermissionLevel, PrivateWebshop, Webshop as WebshopStruct,WebshopPreview, MemberWithRegistrationsBlob } from '@stamhoofd/structures';
2
+ import { Group, MemberPlatformMembership, MemberResponsibilityRecord, MemberWithRegistrations, Organization, OrganizationRegistrationPeriod, Payment, RegistrationPeriod, User, Webshop } from "@stamhoofd/models";
3
+ import { MemberPlatformMembership as MemberPlatformMembershipStruct, MemberResponsibilityRecord as MemberResponsibilityRecordStruct, MemberWithRegistrationsBlob, MembersBlob, Organization as OrganizationStruct, PaymentGeneral, PermissionLevel, PrivateWebshop, User as UserStruct, WebshopPreview, Webshop as WebshopStruct } from '@stamhoofd/structures';
4
4
 
5
5
  import { Context } from "./Context";
6
6
 
@@ -144,10 +144,12 @@ export class AuthenticatedStructures {
144
144
  }
145
145
 
146
146
  // Load responsibilities
147
- const responsibilities = await MemberResponsibilityRecord.where({ memberId: { sign: 'IN', value: members.map(m => m.id) } })
147
+ const responsibilities = members.length > 0 ? await MemberResponsibilityRecord.where({ memberId: { sign: 'IN', value: members.map(m => m.id) } }) : []
148
+ const platformMemberships = members.length > 0 ? await MemberPlatformMembership.where({ memberId: { sign: 'IN', value: members.map(m => m.id) } }) : []
148
149
 
149
150
  for (const blob of memberBlobs) {
150
151
  blob.responsibilities = responsibilities.filter(r => r.memberId == blob.id).map(r => MemberResponsibilityRecordStruct.create(r))
152
+ blob.platformMemberships = platformMemberships.filter(r => r.memberId == blob.id).map(r => MemberPlatformMembershipStruct.create(r))
151
153
  }
152
154
 
153
155
  return MembersBlob.create({