@stamhoofd/backend 2.2.0 → 2.4.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.
Files changed (33) hide show
  1. package/.env.json +2 -2
  2. package/index.ts +3 -0
  3. package/package.json +4 -4
  4. package/src/endpoints/admin/memberships/GetChargeMembershipsSummaryEndpoint.ts +63 -2
  5. package/src/endpoints/admin/organizations/GetOrganizationsEndpoint.ts +3 -0
  6. package/src/endpoints/auth/CreateAdminEndpoint.ts +6 -3
  7. package/src/endpoints/auth/GetOtherUserEndpoint.ts +41 -0
  8. package/src/endpoints/auth/GetUserEndpoint.ts +6 -28
  9. package/src/endpoints/auth/PatchUserEndpoint.ts +25 -6
  10. package/src/endpoints/auth/SignupEndpoint.ts +2 -2
  11. package/src/endpoints/global/email/CreateEmailEndpoint.ts +120 -0
  12. package/src/endpoints/global/email/GetEmailEndpoint.ts +51 -0
  13. package/src/endpoints/global/email/PatchEmailEndpoint.ts +108 -0
  14. package/src/endpoints/global/events/GetEventsEndpoint.ts +223 -0
  15. package/src/endpoints/global/events/PatchEventsEndpoint.ts +319 -0
  16. package/src/endpoints/global/members/GetMembersEndpoint.ts +124 -48
  17. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +86 -109
  18. package/src/endpoints/global/platform/GetPlatformAdminsEndpoint.ts +2 -1
  19. package/src/endpoints/global/platform/PatchPlatformEnpoint.ts +9 -0
  20. package/src/endpoints/global/registration/PatchUserMembersEndpoint.ts +3 -2
  21. package/src/endpoints/organization/dashboard/email/EmailEndpoint.ts +1 -1
  22. package/src/endpoints/organization/dashboard/email-templates/GetEmailTemplatesEndpoint.ts +43 -25
  23. package/src/endpoints/organization/dashboard/email-templates/PatchEmailTemplatesEndpoint.ts +26 -7
  24. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +23 -22
  25. package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.ts +136 -123
  26. package/src/endpoints/organization/dashboard/stripe/DeleteStripeAccountEndpoint.ts +8 -8
  27. package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.ts +2 -1
  28. package/src/helpers/AdminPermissionChecker.ts +54 -3
  29. package/src/helpers/AuthenticatedStructures.ts +88 -23
  30. package/src/helpers/Context.ts +4 -0
  31. package/src/helpers/EmailResumer.ts +17 -0
  32. package/src/helpers/MemberUserSyncer.ts +221 -0
  33. package/src/seeds/1722256498-group-update-occupancy.ts +52 -0
@@ -0,0 +1,223 @@
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 { Event } from '@stamhoofd/models';
6
+ import { SQL, SQLFilterDefinitions, SQLOrderBy, SQLOrderByDirection, SQLSortDefinitions, baseSQLFilterCompilers, compileToSQLFilter, compileToSQLSorter, createSQLColumnFilterCompiler, createSQLExpressionFilterCompiler } from "@stamhoofd/sql";
7
+ import { CountFilteredRequest, Event as EventStruct, LimitedFilteredRequest, PaginatedResponse, StamhoofdFilter, getSortFilter } from '@stamhoofd/structures';
8
+ import { Formatter } from '@stamhoofd/utility';
9
+
10
+ import { AuthenticatedStructures } from '../../../helpers/AuthenticatedStructures';
11
+ import { Context } from '../../../helpers/Context';
12
+
13
+ type Params = Record<string, never>;
14
+ type Query = LimitedFilteredRequest;
15
+ type Body = undefined;
16
+ type ResponseBody = PaginatedResponse<EventStruct[], LimitedFilteredRequest>
17
+
18
+ const filterCompilers: SQLFilterDefinitions = {
19
+ ...baseSQLFilterCompilers,
20
+ id: createSQLColumnFilterCompiler('id'),
21
+ name: createSQLColumnFilterCompiler('name'),
22
+ organizationId: createSQLColumnFilterCompiler('organizationId'),
23
+ startDate: createSQLColumnFilterCompiler('startDate'),
24
+ endDate: createSQLColumnFilterCompiler('endDate'),
25
+ groupIds: createSQLExpressionFilterCompiler(
26
+ SQL.jsonValue(SQL.column('meta'), '$.value.groupIds'),
27
+ undefined,
28
+ true,
29
+ true
30
+ ),
31
+ defaultAgeGroupIds: createSQLExpressionFilterCompiler(
32
+ SQL.jsonValue(SQL.column('meta'), '$.value.defaultAgeGroupIds'),
33
+ undefined,
34
+ true,
35
+ true
36
+ ),
37
+ organizationTagIds: createSQLExpressionFilterCompiler(
38
+ SQL.jsonValue(SQL.column('meta'), '$.value.organizationTagIds')
39
+ )
40
+ }
41
+
42
+ const sorters: SQLSortDefinitions<Event> = {
43
+ 'id': {
44
+ getValue(a) {
45
+ return a.id
46
+ },
47
+ toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
48
+ return new SQLOrderBy({
49
+ column: SQL.column('id'),
50
+ direction
51
+ })
52
+ }
53
+ },
54
+ 'name': {
55
+ getValue(a) {
56
+ return a.name
57
+ },
58
+ toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
59
+ return new SQLOrderBy({
60
+ column: SQL.column('name'),
61
+ direction
62
+ })
63
+ }
64
+ },
65
+ 'startDate': {
66
+ getValue(a) {
67
+ return Formatter.dateTimeIso(a.startDate)
68
+ },
69
+ toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
70
+ return new SQLOrderBy({
71
+ column: SQL.column('startDate'),
72
+ direction
73
+ })
74
+ }
75
+ },
76
+ 'endDate': {
77
+ getValue(a) {
78
+ return Formatter.dateTimeIso(a.endDate)
79
+ },
80
+ toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
81
+ return new SQLOrderBy({
82
+ column: SQL.column('endDate'),
83
+ direction
84
+ })
85
+ }
86
+ },
87
+ }
88
+
89
+ export class GetEventsEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
90
+ queryDecoder = LimitedFilteredRequest as Decoder<LimitedFilteredRequest>
91
+
92
+ protected doesMatch(request: Request): [true, Params] | [false] {
93
+ if (request.method != "GET") {
94
+ return [false];
95
+ }
96
+
97
+ const params = Endpoint.parseParameters(request.url, "/events", {});
98
+
99
+ if (params) {
100
+ return [true, params as Params];
101
+ }
102
+ return [false];
103
+ }
104
+
105
+ static buildQuery(q: CountFilteredRequest|LimitedFilteredRequest) {
106
+ const organization = Context.organization
107
+ let scopeFilter: StamhoofdFilter|undefined = undefined;
108
+
109
+ if (organization) {
110
+ scopeFilter = {
111
+ $or: [
112
+ {
113
+ organizationId: organization.id
114
+ },
115
+ {
116
+ organizationId: null
117
+ }
118
+ ]
119
+ };
120
+ }
121
+
122
+ const query = SQL
123
+ .select(
124
+ SQL.wildcard(Event.table)
125
+ )
126
+ .from(
127
+ SQL.table(Event.table)
128
+ );
129
+
130
+ if (scopeFilter) {
131
+ query.where(compileToSQLFilter(scopeFilter, filterCompilers))
132
+ }
133
+
134
+ if (q.filter) {
135
+ query.where(compileToSQLFilter(q.filter, filterCompilers))
136
+ }
137
+
138
+ if (q.search) {
139
+ let searchFilter: StamhoofdFilter|null = null
140
+
141
+ // todo: detect special search patterns and adjust search filter if needed
142
+ searchFilter = {
143
+ name: {
144
+ $contains: q.search
145
+ }
146
+ }
147
+
148
+ if (searchFilter) {
149
+ query.where(compileToSQLFilter(searchFilter, filterCompilers))
150
+ }
151
+ }
152
+
153
+ if (q instanceof LimitedFilteredRequest) {
154
+ if (q.pageFilter) {
155
+ query.where(compileToSQLFilter(q.pageFilter, filterCompilers))
156
+ }
157
+
158
+ query.orderBy(compileToSQLSorter(q.sort, sorters))
159
+ query.limit(q.limit)
160
+ }
161
+
162
+ return query
163
+ }
164
+
165
+ static async buildData(requestQuery: LimitedFilteredRequest) {
166
+ const query = GetEventsEndpoint.buildQuery(requestQuery)
167
+ const data = await query.fetch()
168
+
169
+ const events = Event.fromRows(data, Event.table);
170
+
171
+ let next: LimitedFilteredRequest|undefined;
172
+
173
+ if (events.length >= requestQuery.limit) {
174
+ const lastObject = events[events.length - 1];
175
+ const nextFilter = getSortFilter(lastObject, sorters, requestQuery.sort);
176
+
177
+ next = new LimitedFilteredRequest({
178
+ filter: requestQuery.filter,
179
+ pageFilter: nextFilter,
180
+ sort: requestQuery.sort,
181
+ limit: requestQuery.limit,
182
+ search: requestQuery.search
183
+ })
184
+
185
+ if (JSON.stringify(nextFilter) === JSON.stringify(requestQuery.pageFilter)) {
186
+ console.error('Found infinite loading loop for', requestQuery);
187
+ next = undefined;
188
+ }
189
+ }
190
+
191
+ return new PaginatedResponse<EventStruct[], LimitedFilteredRequest>({
192
+ results: await AuthenticatedStructures.events(events),
193
+ next
194
+ });
195
+ }
196
+
197
+ async handle(request: DecodedRequest<Params, Query, Body>) {
198
+ await Context.setOptionalOrganizationScope();
199
+ await Context.authenticate()
200
+
201
+ const maxLimit = Context.auth.hasSomePlatformAccess() ? 1000 : 100;
202
+
203
+ if (request.query.limit > maxLimit) {
204
+ throw new SimpleError({
205
+ code: 'invalid_field',
206
+ field: 'limit',
207
+ message: 'Limit can not be more than ' + maxLimit
208
+ })
209
+ }
210
+
211
+ if (request.query.limit < 1) {
212
+ throw new SimpleError({
213
+ code: 'invalid_field',
214
+ field: 'limit',
215
+ message: 'Limit can not be less than 1'
216
+ })
217
+ }
218
+
219
+ return new Response(
220
+ await GetEventsEndpoint.buildData(request.query)
221
+ );
222
+ }
223
+ }
@@ -0,0 +1,319 @@
1
+ import { AutoEncoderPatchType, Decoder, PatchableArrayAutoEncoder, PatchableArrayDecoder, patchObject, StringDecoder } from '@simonbackx/simple-encoding';
2
+ import { DecodedRequest, Endpoint, Request, Response } from "@simonbackx/simple-endpoints";
3
+ import { Event, Organization, Platform, RegistrationPeriod } from '@stamhoofd/models';
4
+ import { Event as EventStruct, GroupType, PermissionLevel } from "@stamhoofd/structures";
5
+
6
+ import { SimpleError } from '@simonbackx/simple-errors';
7
+ import { SQL, SQLWhereSign } from '@stamhoofd/sql';
8
+ import { Formatter } from '@stamhoofd/utility';
9
+ import { AuthenticatedStructures } from '../../../helpers/AuthenticatedStructures';
10
+ import { Context } from '../../../helpers/Context';
11
+ import { PatchOrganizationRegistrationPeriodsEndpoint } from '../../organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint';
12
+
13
+ type Params = { id: string };
14
+ type Query = undefined;
15
+ type Body = PatchableArrayAutoEncoder<EventStruct>
16
+ type ResponseBody = EventStruct[]
17
+
18
+ export class PatchEventsEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
19
+ bodyDecoder = new PatchableArrayDecoder(EventStruct as Decoder<EventStruct>, EventStruct.patchType() as Decoder<AutoEncoderPatchType<EventStruct>>, StringDecoder)
20
+
21
+ protected doesMatch(request: Request): [true, Params] | [false] {
22
+ if (request.method != "PATCH") {
23
+ return [false];
24
+ }
25
+
26
+ const params = Endpoint.parseParameters(request.url, "/events", { id: String });
27
+
28
+ if (params) {
29
+ return [true, params as Params];
30
+ }
31
+ return [false];
32
+ }
33
+
34
+ async handle(request: DecodedRequest<Params, Query, Body>) {
35
+ const organization = await Context.setOptionalOrganizationScope();
36
+ await Context.authenticate()
37
+
38
+ if (organization) {
39
+ if (!await Context.auth.hasSomeAccess(organization.id)) {
40
+ throw Context.auth.error()
41
+ }
42
+ } else {
43
+ if (!Context.auth.hasSomePlatformAccess()) {
44
+ throw Context.auth.error()
45
+ }
46
+ }
47
+
48
+ const events: Event[] = [];
49
+
50
+ for (const {put} of request.body.getPuts()) {
51
+ if (organization?.id && put.organizationId !== organization.id) {
52
+ throw new SimpleError({
53
+ code: 'invalid_data',
54
+ message: 'Invalid organizationId',
55
+ human: 'Je kan geen activiteiten aanmaken voor een andere organisatie',
56
+ })
57
+ }
58
+
59
+ if (!organization?.id && !Context.auth.hasPlatformFullAccess()) {
60
+ throw new SimpleError({
61
+ code: 'invalid_data',
62
+ message: 'Invalid organizationId',
63
+ human: 'Je kan geen activiteiten voor een specifieke organisatie aanmaken als je geen platform hoofdbeheerder bent',
64
+ })
65
+ }
66
+
67
+ const eventOrganization = put.organizationId ? (await Organization.getByID(put.organizationId)) : null
68
+ if (!eventOrganization && put.organizationId) {
69
+ throw new SimpleError({
70
+ code: 'invalid_data',
71
+ message: 'Invalid organizationId',
72
+ human: 'De organisatie werd niet gevonden',
73
+ })
74
+ }
75
+
76
+ const event = new Event()
77
+ event.id = put.id
78
+ event.organizationId = put.organizationId
79
+ event.name = put.name
80
+ event.startDate = put.startDate
81
+ event.endDate = put.endDate
82
+ event.meta = put.meta
83
+
84
+ if (put.group) {
85
+ const period = await RegistrationPeriod.getByDate(event.startDate)
86
+
87
+ if (!period) {
88
+ throw new SimpleError({
89
+ code: 'invalid_period',
90
+ message: 'No period found for this start date',
91
+ human: 'Oeps, je kan nog geen evenementen met inschrijvingen aanmaken in deze periode. Dit werkjaar is nog niet aangemaakt in het systeem.',
92
+ field: 'startDate'
93
+ })
94
+ }
95
+
96
+ put.group.type = GroupType.EventRegistration
97
+ const group = await PatchOrganizationRegistrationPeriodsEndpoint.createGroup(
98
+ put.group,
99
+ put.group.organizationId,
100
+ period.id
101
+ )
102
+ event.groupId = group.id
103
+
104
+ }
105
+ event.typeId = await PatchEventsEndpoint.validateEventType(put.typeId)
106
+ await PatchEventsEndpoint.checkEventLimits(event)
107
+
108
+ if (!(await Context.auth.canAccessEvent(event, PermissionLevel.Full))) {
109
+ throw Context.auth.error()
110
+ }
111
+
112
+ await event.save()
113
+
114
+ events.push(event)
115
+ }
116
+
117
+ for (const patch of request.body.getPatches()) {
118
+ const event = await Event.getByID(patch.id)
119
+
120
+ if (!event || !(await Context.auth.canAccessEvent(event, PermissionLevel.Full))) {
121
+ throw new SimpleError({
122
+ code: 'not_found',
123
+ message: 'Event not found',
124
+ human: 'De activiteit werd niet gevonden',
125
+ })
126
+ }
127
+
128
+ event.name = patch.name ?? event.name
129
+ event.startDate = patch.startDate ?? event.startDate
130
+ event.endDate = patch.endDate ?? event.endDate
131
+ event.meta = patchObject(event.meta, patch.meta)
132
+
133
+
134
+ if (patch.organizationId !== undefined) {
135
+ if (organization?.id && patch.organizationId !== organization.id) {
136
+ throw new SimpleError({
137
+ code: 'invalid_data',
138
+ message: 'Invalid organizationId',
139
+ human: 'Je kan geen activiteiten aanmaken voor een andere organisatie',
140
+ })
141
+ }
142
+
143
+ if (!organization?.id && !Context.auth.hasPlatformFullAccess()) {
144
+ throw new SimpleError({
145
+ code: 'invalid_data',
146
+ message: 'Invalid organizationId',
147
+ human: 'Je kan geen activiteiten voor een specifieke organisatie aanmaken als je geen platform hoofdbeheerder bent',
148
+ })
149
+ }
150
+
151
+ const eventOrganization = patch.organizationId ? (await Organization.getByID(patch.organizationId)) : null
152
+ if (!eventOrganization && patch.organizationId) {
153
+ throw new SimpleError({
154
+ code: 'invalid_data',
155
+ message: 'Invalid organizationId',
156
+ human: 'De organisatie werd niet gevonden',
157
+ })
158
+ }
159
+ event.organizationId = patch.organizationId
160
+ }
161
+
162
+ event.typeId = patch.typeId ? (await PatchEventsEndpoint.validateEventType(patch.typeId)) : event.typeId
163
+ await PatchEventsEndpoint.checkEventLimits(event)
164
+
165
+ if (patch.group !== undefined) {
166
+ if (patch.group === null) {
167
+ // delete
168
+ if (event.groupId) {
169
+ await PatchOrganizationRegistrationPeriodsEndpoint.deleteGroup(event.groupId)
170
+ event.groupId = null;
171
+ }
172
+
173
+ } else if (patch.group.isPatch()) {
174
+ if (!event.groupId) {
175
+ throw new SimpleError({
176
+ code: 'invalid_field',
177
+ field: 'group',
178
+ message: 'Cannot patch group before it is created'
179
+ })
180
+ }
181
+ patch.group.id = event.groupId
182
+ patch.group.type = GroupType.EventRegistration
183
+ await PatchOrganizationRegistrationPeriodsEndpoint.patchGroup(patch.group)
184
+ } else {
185
+ if (event.groupId) {
186
+ // need to delete old group first
187
+ await PatchOrganizationRegistrationPeriodsEndpoint.deleteGroup(event.groupId)
188
+ event.groupId = null;
189
+ }
190
+ patch.group.type = GroupType.EventRegistration
191
+
192
+ const period = await RegistrationPeriod.getByDate(event.startDate)
193
+
194
+ if (!period) {
195
+ throw new SimpleError({
196
+ code: 'invalid_period',
197
+ message: 'No period found for this start date',
198
+ human: 'Oeps, je kan nog geen evenementen met inschrijvingen aanmaken in deze periode. Dit werkjaar is nog niet aangemaakt in het systeem.',
199
+ field: 'startDate'
200
+ })
201
+ }
202
+
203
+ const group = await PatchOrganizationRegistrationPeriodsEndpoint.createGroup(
204
+ patch.group,
205
+ patch.group.organizationId,
206
+ period.id
207
+ )
208
+ event.groupId = group.id
209
+ }
210
+ }
211
+
212
+ await event.save()
213
+ events.push(event)
214
+ }
215
+
216
+ return new Response(
217
+ await AuthenticatedStructures.events(events)
218
+ );
219
+ }
220
+
221
+ static async validateEventType(typeId: string) {
222
+ return (await this.getEventType(typeId)).id
223
+ }
224
+
225
+ static async getEventType(typeId: string) {
226
+ const platform = await Platform.getSharedStruct();
227
+ const type = platform.config.eventTypes.find(t => t.id == typeId)
228
+ if (!type) {
229
+ throw new SimpleError({
230
+ code: 'invalid_field',
231
+ message: 'Invalid typeId',
232
+ human: 'Dit type activiteit wordt niet ondersteund',
233
+ field: 'typeId'
234
+ })
235
+ }
236
+ return type
237
+ }
238
+
239
+ static async checkEventLimits(event: Event) {
240
+ const type = await this.getEventType(event.typeId)
241
+
242
+ if (event.name.length < 2) {
243
+ throw new SimpleError({
244
+ code: 'invalid_field',
245
+ message: 'Name is too short',
246
+ human: 'Vul een naam voor je activiteit in',
247
+ field: 'name'
248
+ })
249
+ }
250
+
251
+ if (event.endDate < event.startDate) {
252
+ throw new SimpleError({
253
+ code: 'invalid_dates',
254
+ message: 'End date is before start date',
255
+ human: 'De einddatum moet na de startdatum liggen',
256
+ field: 'endDate'
257
+ })
258
+ }
259
+
260
+ if (type.maximumDays !== null || type.minimumDays !== null) {
261
+ const start = Formatter.luxon(event.startDate).startOf('day')
262
+ const end = Formatter.luxon(event.endDate).startOf('day')
263
+
264
+ const days = end.diff(start, 'days').days + 1;
265
+
266
+ console.log('Detected days:', days)
267
+
268
+ if (type.minimumDays !== null && days < type.minimumDays) {
269
+ throw new SimpleError({
270
+ code: 'minimum_days',
271
+ message: 'An event with this type has a minimum of ' + type.minimumDays + ' days',
272
+ human: 'Een ' + type.name + ' moet minimum ' + Formatter.pluralText(type.minimumDays, 'dag', 'dagen') + ' duren',
273
+ field: 'startDate'
274
+ })
275
+ }
276
+
277
+ if (type.maximumDays !== null && days > type.maximumDays) {
278
+ throw new SimpleError({
279
+ code: 'maximum_days',
280
+ message: 'An event with this type has a maximum of ' + type.maximumDays + ' days',
281
+ human: 'Een ' + type.name + ' mag maximaal ' + Formatter.pluralText(type.maximumDays, 'dag', 'dagen') + ' duren',
282
+ field: 'startDate'
283
+ })
284
+ }
285
+ }
286
+
287
+ if (type.maximum && (!event.existsInDatabase || ("typeId" in (await event.getChangedDatabaseProperties()).fields))) {
288
+ const currentPeriod = await RegistrationPeriod.getByDate(event.startDate);
289
+ console.log('event.startDate', event.startDate)
290
+ if (currentPeriod) {
291
+ const count = await SQL.select().from(
292
+ SQL.table(Event.table)
293
+ )
294
+ .where(SQL.column('organizationId'), event.organizationId)
295
+ .where(SQL.column('typeId'), event.typeId)
296
+ .where(SQL.column('id'), SQLWhereSign.NotEqual, event.id)
297
+ .where(SQL.column('startDate'), SQLWhereSign.GreaterEqual, currentPeriod.startDate)
298
+ .where(SQL.column('endDate'), SQLWhereSign.LessEqual, currentPeriod.endDate)
299
+ .count()
300
+
301
+ if (count >= type.maximum) {
302
+ throw new SimpleError({
303
+ code: 'type_maximum_reached',
304
+ message: 'Maximum number of events with this type reached',
305
+ human: 'Het maximum aantal voor ' + type.name + ' is bereikt (' + type.maximum + ')',
306
+ field: 'typeId'
307
+ })
308
+ }
309
+ } else {
310
+ throw new SimpleError({
311
+ code: 'invalid_period',
312
+ message: 'No period found for this start date',
313
+ human: 'Oeps, je kan nog geen evenementen van dit type aanmaken in deze periode',
314
+ field: 'startDate'
315
+ })
316
+ }
317
+ }
318
+ }
319
+ }