@stamhoofd/backend 2.36.2 → 2.38.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 (24) hide show
  1. package/package.json +10 -10
  2. package/src/endpoints/admin/memberships/ChargeMembershipsEndpoint.ts +1 -2
  3. package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.ts +8 -0
  4. package/src/endpoints/auth/CreateAdminEndpoint.ts +4 -3
  5. package/src/endpoints/auth/PatchUserEndpoint.ts +2 -2
  6. package/src/endpoints/global/addresses/SearchRegionsEndpoint.ts +12 -0
  7. package/src/endpoints/global/email/CreateEmailEndpoint.ts +2 -0
  8. package/src/endpoints/global/email/PatchEmailEndpoint.ts +6 -0
  9. package/src/endpoints/global/events/PatchEventsEndpoint.ts +1 -1
  10. package/src/endpoints/global/files/ExportToExcelEndpoint.ts +9 -10
  11. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +46 -24
  12. package/src/endpoints/global/platform/PatchPlatformEnpoint.ts +11 -4
  13. package/src/endpoints/global/registration/PatchUserMembersEndpoint.ts +2 -0
  14. package/src/endpoints/global/registration/RegisterMembersEndpoint.test.ts +453 -0
  15. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +19 -2
  16. package/src/endpoints/organization/shared/ExchangePaymentEndpoint.ts +13 -2
  17. package/src/endpoints/organization/shared/GetPaymentEndpoint.ts +1 -1
  18. package/src/excel-loaders/members.ts +87 -24
  19. package/src/helpers/AddressValidator.ts +11 -0
  20. package/src/helpers/AdminPermissionChecker.ts +14 -1
  21. package/src/helpers/Context.ts +2 -2
  22. package/src/helpers/MembershipCharger.ts +84 -2
  23. package/src/helpers/fetchToAsyncIterator.ts +3 -4
  24. package/src/seeds/1726494420-update-cached-outstanding-balance-from-items.ts +40 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stamhoofd/backend",
3
- "version": "2.36.2",
3
+ "version": "2.38.0",
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.36.2",
40
- "@stamhoofd/backend-middleware": "2.36.2",
41
- "@stamhoofd/email": "2.36.2",
42
- "@stamhoofd/models": "2.36.2",
43
- "@stamhoofd/queues": "2.36.2",
44
- "@stamhoofd/sql": "2.36.2",
45
- "@stamhoofd/structures": "2.36.2",
46
- "@stamhoofd/utility": "2.36.2",
39
+ "@stamhoofd/backend-i18n": "2.38.0",
40
+ "@stamhoofd/backend-middleware": "2.38.0",
41
+ "@stamhoofd/email": "2.38.0",
42
+ "@stamhoofd/models": "2.38.0",
43
+ "@stamhoofd/queues": "2.38.0",
44
+ "@stamhoofd/sql": "2.38.0",
45
+ "@stamhoofd/structures": "2.38.0",
46
+ "@stamhoofd/utility": "2.38.0",
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": "7802a792419ab01fd96ac9e0db86f7553b3e6619"
63
+ "gitHead": "4af6773c9729d690145d60993542452989d6219d"
64
64
  }
@@ -1,9 +1,8 @@
1
1
  import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
2
+ import { SimpleError } from '@simonbackx/simple-errors';
2
3
  import { QueueHandler } from '@stamhoofd/queues';
3
- import { sleep } from '@stamhoofd/utility';
4
4
  import { Context } from '../../../helpers/Context';
5
5
  import { MembershipCharger } from '../../../helpers/MembershipCharger';
6
- import { SimpleError } from '@simonbackx/simple-errors';
7
6
 
8
7
 
9
8
  type Params = Record<string, never>;
@@ -43,6 +43,10 @@ export class PatchOrganizationsEndpoint extends Endpoint<Params, Query, Body, Re
43
43
  const platform = await Platform.getShared()
44
44
 
45
45
  for (const id of request.body.getDeletes()) {
46
+ if (!Context.auth.hasPlatformFullAccess()) {
47
+ throw Context.auth.error('Enkel een platform hoofdbeheerder kan groepen verwijderen')
48
+ }
49
+
46
50
  const organization = await Organization.getByID(id);
47
51
  if (!organization) {
48
52
  throw new SimpleError({ code: "not_found", message: "Organization not found", statusCode: 404 });
@@ -85,6 +89,10 @@ export class PatchOrganizationsEndpoint extends Endpoint<Params, Query, Body, Re
85
89
 
86
90
  // Organization creation
87
91
  for (const {put} of request.body.getPuts()) {
92
+ if (!Context.auth.hasPlatformFullAccess()) {
93
+ throw Context.auth.error('Enkel een platform hoofdbeheerder kan nieuwe groepen aanmaken')
94
+ }
95
+
88
96
  if (put.name.length < 4) {
89
97
  if (put.name.length == 0) {
90
98
  throw new SimpleError({
@@ -1,7 +1,7 @@
1
1
  import { Decoder } from '@simonbackx/simple-encoding';
2
2
  import { DecodedRequest, Endpoint, Request, Response } from "@simonbackx/simple-endpoints";
3
3
  import { SimpleError } from "@simonbackx/simple-errors";
4
- import { PasswordToken, sendEmailTemplate, User } from '@stamhoofd/models';
4
+ import { PasswordToken, Platform, sendEmailTemplate, User } from '@stamhoofd/models';
5
5
  import { EmailTemplateType, Recipient, Replacement, UserPermissions, User as UserStruct, UserWithMembers } from "@stamhoofd/structures";
6
6
  import { Formatter } from '@stamhoofd/utility';
7
7
 
@@ -100,9 +100,10 @@ export class CreateAdminEndpoint extends Endpoint<Params, Query, Body, ResponseB
100
100
 
101
101
  const dateTime = Formatter.dateTime(validUntil)
102
102
  const recoveryUrl = await PasswordToken.getPasswordRecoveryUrl(admin, organization, request.i18n, validUntil)
103
+ const platformName = ((await Platform.getSharedStruct()).config.name)
103
104
 
104
- const name = organization?.name ?? request.i18n.t("shared.platformName")
105
- const what = organization ? `de vereniging ${name} op ${request.i18n.t("shared.platformName")}` : `${request.i18n.t("shared.platformName")}`
105
+ const name = organization?.name ?? platformName
106
+ const what = organization ? `de vereniging ${name} op ${platformName}` : platformName
106
107
 
107
108
  const emailTo = admin.getEmailTo();
108
109
  const email: string = typeof emailTo === 'string' ? emailTo : emailTo[0]?.email;
@@ -79,7 +79,7 @@ export class PatchUserEndpoint extends Endpoint<Params, Query, Body, ResponseBod
79
79
  if (organization) {
80
80
  editUser.permissions = UserPermissions.limitedPatch(editUser.permissions, request.body.permissions, organization.id)
81
81
 
82
- if (editUser.id === user.id && (!editUser.permissions || !editUser.permissions.forOrganization(organization)?.hasFullAccess())) {
82
+ if (editUser.id === user.id && (!editUser.permissions || !editUser.permissions.forOrganization(organization)?.hasFullAccess()) && STAMHOOFD.environment !== 'development') {
83
83
  throw new SimpleError({
84
84
  code: "permission_denied",
85
85
  message: "Je kan jezelf niet verwijderen als hoofdbeheerder"
@@ -96,7 +96,7 @@ export class PatchUserEndpoint extends Endpoint<Params, Query, Body, ResponseBod
96
96
  editUser.permissions = null
97
97
  }
98
98
 
99
- if (editUser.id === user.id && !editUser.permissions?.platform?.hasFullAccess()) {
99
+ if (editUser.id === user.id && !editUser.permissions?.platform?.hasFullAccess() && STAMHOOFD.environment !== 'development') {
100
100
  throw new SimpleError({
101
101
  code: "permission_denied",
102
102
  message: "Je kan jezelf niet verwijderen als hoofdbeheerder"
@@ -85,6 +85,18 @@ export class SearchRegionsEndpoint extends Endpoint<Params, Query, Body, Respons
85
85
  if (StringCompare.typoCount(request.query.query, "Nederland") < 3) {
86
86
  countries.push(Country.Netherlands)
87
87
  }
88
+
89
+ if (StringCompare.typoCount(request.query.query, "Luxemburg") < 3) {
90
+ countries.push(Country.Luxembourg)
91
+ }
92
+
93
+ if (StringCompare.typoCount(request.query.query, "Duitsland") < 3) {
94
+ countries.push(Country.Germany)
95
+ }
96
+
97
+ if (StringCompare.typoCount(request.query.query, "Frankrijk") < 3) {
98
+ countries.push(Country.France)
99
+ }
88
100
 
89
101
  return new Response(SearchRegions.create({
90
102
  cities: loadedCities.map(c => CityStruct.create(Object.assign({...c}, { province: ProvinceStruct.create(c.province) }))),
@@ -83,6 +83,8 @@ export class CreateEmailEndpoint extends Endpoint<Params, Query, Body, ResponseB
83
83
  model.fromAddress = request.body.fromAddress;
84
84
  model.fromName = request.body.fromName;
85
85
 
86
+ model.validateAttachments()
87
+
86
88
  // Check default
87
89
  if (JSON.stringify(model.json).length < 3 && model.recipientFilter.filters[0].type && EmailTemplateStruct.getDefaultForRecipient(model.recipientFilter.filters[0].type)) {
88
90
  const type = EmailTemplateStruct.getDefaultForRecipient(model.recipientFilter.filters[0].type)
@@ -89,6 +89,12 @@ export class PatchEmailEndpoint extends Endpoint<Params, Query, Body, ResponseBo
89
89
  rebuild = true;
90
90
  }
91
91
 
92
+ // Attachments
93
+ if (request.body.attachments !== undefined) {
94
+ model.attachments = patchObject(model.attachments, request.body.attachments);
95
+ model.validateAttachments()
96
+ }
97
+
92
98
  await model.save();
93
99
 
94
100
  if (rebuild) {
@@ -60,7 +60,7 @@ export class PatchEventsEndpoint extends Endpoint<Params, Query, Body, ResponseB
60
60
  throw new SimpleError({
61
61
  code: 'invalid_data',
62
62
  message: 'Invalid organizationId',
63
- human: 'Je kan geen activiteiten voor een specifieke organisatie aanmaken als je geen platform hoofdbeheerder bent',
63
+ human: 'Je kan activiteiten aanmaken via het administratieportaal als je geen platform hoofdbeheerder bent',
64
64
  })
65
65
  }
66
66
 
@@ -1,24 +1,23 @@
1
1
  /* eslint-disable @typescript-eslint/no-unsafe-argument */
2
- import { Decoder, EncodableObject } from '@simonbackx/simple-encoding';
2
+ import { Decoder } 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 { Email } from '@stamhoofd/email';
6
5
  import { ArchiverWriterAdapter, exportToExcel, XlsxTransformerSheet, XlsxWriter } from '@stamhoofd/excel-writer';
7
- import { getEmailBuilderForTemplate, Platform, RateLimiter, sendEmailTemplate } from '@stamhoofd/models';
8
- import { EmailTemplateType, ExcelExportRequest, ExcelExportResponse, ExcelExportType, LimitedFilteredRequest, PaginatedResponse, Recipient, Replacement, Version } from '@stamhoofd/structures';
6
+ import { Platform, RateLimiter, sendEmailTemplate } from '@stamhoofd/models';
7
+ import { QueueHandler } from '@stamhoofd/queues';
8
+ import { EmailTemplateType, ExcelExportRequest, ExcelExportResponse, ExcelExportType, IPaginatedResponse, LimitedFilteredRequest, Replacement, Version } from '@stamhoofd/structures';
9
9
  import { sleep } from "@stamhoofd/utility";
10
10
  import { Context } from '../../../helpers/Context';
11
11
  import { fetchToAsyncIterator } from '../../../helpers/fetchToAsyncIterator';
12
12
  import { FileCache } from '../../../helpers/FileCache';
13
- import { QueueHandler } from '@stamhoofd/queues';
14
13
 
15
14
  type Params = { type: string };
16
15
  type Query = undefined;
17
16
  type Body = ExcelExportRequest;
18
17
  type ResponseBody = ExcelExportResponse;
19
18
 
20
- type ExcelExporter<T extends EncodableObject> = {
21
- fetch(request: LimitedFilteredRequest): Promise<PaginatedResponse<T[], LimitedFilteredRequest>>
19
+ type ExcelExporter<T> = {
20
+ fetch(request: LimitedFilteredRequest): Promise<IPaginatedResponse<T[], LimitedFilteredRequest>>
22
21
  sheets: XlsxTransformerSheet<T, unknown>[]
23
22
  }
24
23
 
@@ -36,7 +35,7 @@ export class ExportToExcelEndpoint extends Endpoint<Params, Query, Body, Respons
36
35
  bodyDecoder = ExcelExportRequest as Decoder<ExcelExportRequest>
37
36
 
38
37
  // Other endpoints can register exports here
39
- static loaders: Map<ExcelExportType, ExcelExporter<EncodableObject>> = new Map()
38
+ static loaders: Map<ExcelExportType, ExcelExporter<unknown>> = new Map()
40
39
 
41
40
  protected doesMatch(request: Request): [true, Params] | [false] {
42
41
  if (request.method != "POST") {
@@ -52,7 +51,7 @@ export class ExportToExcelEndpoint extends Endpoint<Params, Query, Body, Respons
52
51
  }
53
52
 
54
53
  async handle(request: DecodedRequest<Params, Query, Body>) {
55
- const organization = await Context.setOptionalOrganizationScope();
54
+ await Context.setOptionalOrganizationScope();
56
55
  const {user} = await Context.authenticate()
57
56
 
58
57
  if (user.isApiUser) {
@@ -138,7 +137,7 @@ export class ExportToExcelEndpoint extends Endpoint<Params, Query, Body, Respons
138
137
  }))
139
138
  }
140
139
 
141
- async job(loader: ExcelExporter<EncodableObject>, request: ExcelExportRequest, type: string): Promise<string> {
140
+ async job(loader: ExcelExporter<unknown>, request: ExcelExportRequest, type: string): Promise<string> {
142
141
  // Only run 1 export per user at the same time
143
142
  return await QueueHandler.schedule('user-export-to-excel-' + Context.user!.id, async () => {
144
143
  // Allow maximum 2 running Excel jobs at the same time for all users
@@ -10,6 +10,8 @@ import { AuthenticatedStructures } from '../../../helpers/AuthenticatedStructure
10
10
  import { Context } from '../../../helpers/Context';
11
11
  import { MemberUserSyncer } from '../../../helpers/MemberUserSyncer';
12
12
  import { SetupStepUpdater } from '../../../helpers/SetupStepsUpdater';
13
+ import { MembershipCharger } from '../../../helpers/MembershipCharger';
14
+ import { QueueHandler } from '@stamhoofd/queues';
13
15
 
14
16
  type Params = Record<string, never>;
15
17
  type Query = undefined;
@@ -71,9 +73,9 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
71
73
  }
72
74
  return null
73
75
  }
74
- const updateGroups = new Map<string, Group>();
75
- const updateRegistrations = new Map<string, Registration>();
76
+
76
77
  const updateMembershipMemberIds = new Set<string>()
78
+ const updateMembershipsForOrganizations = new Set<string>()
77
79
 
78
80
  // Loop all members one by one
79
81
  for (const put of request.body.getPuts()) {
@@ -471,7 +473,7 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
471
473
  })
472
474
  }
473
475
 
474
- if (!membership.canDelete()) {
476
+ if (!membership.canDelete() && !Context.auth.hasPlatformFullAccess()) {
475
477
  throw new SimpleError({
476
478
  code: "invalid_field",
477
479
  message: "Invalid invoice",
@@ -479,8 +481,8 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
479
481
  })
480
482
  }
481
483
 
482
- membership.deletedAt = new Date()
483
- await membership.save()
484
+ await membership.doDelete();
485
+ updateMembershipsForOrganizations.add(membership.organizationId) // can influence free memberships in other members of same organization
484
486
  updateMembershipMemberIds.add(member.id)
485
487
  }
486
488
 
@@ -489,8 +491,38 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
489
491
  }
490
492
  }
491
493
 
494
+ await PatchOrganizationMembersEndpoint.deleteMembers(request.body.getDeletes())
495
+
496
+ for (const member of members) {
497
+ if (updateMembershipMemberIds.has(member.id)) {
498
+ await member.updateMemberships()
499
+ }
500
+ }
501
+
502
+ if (updateMembershipsForOrganizations.size) {
503
+ QueueHandler.schedule('update-membership-prices', async () => {
504
+ for (const id of updateMembershipsForOrganizations) {
505
+ await MembershipCharger.updatePrices(id)
506
+ }
507
+ }).catch(console.error);
508
+ }
509
+
510
+ if(shouldUpdateSetupSteps && organization) {
511
+ SetupStepUpdater.updateForOrganization(organization).catch(console.error);
512
+ }
513
+
514
+ return new Response(
515
+ await AuthenticatedStructures.membersBlob(members)
516
+ );
517
+ }
518
+
519
+ static async deleteMembers(ids: string[]) {
520
+ const updateGroups = new Set<string>();
521
+ const updateRegistrations = new Map<string, Registration>();
522
+ const updateSteps = new Set<string>();
523
+
492
524
  // Loop all members one by one
493
- for (const id of request.body.getDeletes()) {
525
+ for (const id of ids) {
494
526
  const member = await Member.getWithRegistrations(id)
495
527
  if (!member || !await Context.auth.canDeleteMember(member)) {
496
528
  throw Context.auth.error("Je hebt niet voldoende rechten om dit lid te verwijderen")
@@ -500,16 +532,12 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
500
532
  await User.deleteForDeletedMember(member.id)
501
533
  await BalanceItem.deleteForDeletedMember(member.id)
502
534
  await member.delete()
503
- shouldUpdateSetupSteps = true
504
535
 
505
536
  for(const registration of member.registrations) {
506
537
  const groupId = registration.groupId;
507
- const group = await getGroup(groupId);
508
538
  updateRegistrations.set(registration.id, registration);
509
- if (group) {
510
- // We need to update this group occupancy because we moved one member away from it
511
- updateGroups.set(group.id, group)
512
- }
539
+ updateGroups.add(groupId);
540
+ updateSteps.add(registration.organizationId);
513
541
  }
514
542
  }
515
543
 
@@ -517,25 +545,19 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
517
545
  registration.scheduleStockUpdate();
518
546
  }
519
547
 
548
+ const groups = await Group.getByIDs(...Array.from(updateGroups));
549
+
520
550
  // Loop all groups and update occupancy if needed
521
- for (const group of updateGroups.values()) {
551
+ for (const group of groups) {
522
552
  await group.updateOccupancy()
523
553
  await group.save()
524
554
  }
525
-
526
- for (const member of members) {
527
- if (updateMembershipMemberIds.has(member.id)) {
528
- await member.updateMemberships()
529
- }
530
- }
531
555
 
532
- if(shouldUpdateSetupSteps && organization) {
556
+ const organizations = await Organization.getByIDs(...Array.from(updateSteps));
557
+
558
+ for (const organization of organizations) {
533
559
  SetupStepUpdater.updateForOrganization(organization).catch(console.error);
534
560
  }
535
-
536
- return new Response(
537
- await AuthenticatedStructures.membersBlob(members)
538
- );
539
561
  }
540
562
 
541
563
  static async checkDuplicate(member: Member) {
@@ -1,13 +1,15 @@
1
- import { AutoEncoderPatchType, Decoder, isPatch, isPatchableArray, patchObject } from "@simonbackx/simple-encoding";
1
+ import { AutoEncoderPatchType, Decoder, isPatchableArray, patchObject } from "@simonbackx/simple-encoding";
2
2
  import { DecodedRequest, Endpoint, Request, Response } from "@simonbackx/simple-endpoints";
3
3
  import { Organization, Platform, RegistrationPeriod } from "@stamhoofd/models";
4
4
  import { MemberResponsibility, PlatformConfig, PlatformPremiseType, Platform as PlatformStruct } from "@stamhoofd/structures";
5
5
 
6
6
  import { SimpleError } from "@simonbackx/simple-errors";
7
+ import { QueueHandler } from "@stamhoofd/queues";
7
8
  import { Context } from "../../../helpers/Context";
8
- import { SetupStepUpdater } from "../../../helpers/SetupStepsUpdater";
9
- import { PeriodHelper } from "../../../helpers/PeriodHelper";
9
+ import { MembershipCharger } from "../../../helpers/MembershipCharger";
10
10
  import { MembershipHelper } from "../../../helpers/MembershipHelper";
11
+ import { PeriodHelper } from "../../../helpers/PeriodHelper";
12
+ import { SetupStepUpdater } from "../../../helpers/SetupStepsUpdater";
11
13
 
12
14
  type Params = Record<string, never>;
13
15
  type Query = undefined;
@@ -174,7 +176,12 @@ export class PatchPlatformEndpoint extends Endpoint<
174
176
  await platform.save();
175
177
 
176
178
  if (shouldUpdateMemberships) {
177
- MembershipHelper.updateAll().catch(console.error)
179
+ if (!QueueHandler.isRunning('update-membership-prices')) {
180
+ QueueHandler.schedule('update-membership-prices', async () => {
181
+ await MembershipCharger.updatePrices()
182
+ await MembershipHelper.updateAll()
183
+ }).catch(console.error);
184
+ }
178
185
  }
179
186
 
180
187
  if (shouldMoveToPeriod) {
@@ -134,6 +134,8 @@ export class PatchUserMembersEndpoint extends Endpoint<Params, Query, Body, Resp
134
134
  // Give access to created members
135
135
  await Member.users.reverse("members").link(user, addedMembers)
136
136
  }
137
+
138
+ await PatchOrganizationMembersEndpoint.deleteMembers(request.body.getDeletes())
137
139
 
138
140
  members = await Member.getMembersWithRegistrationForUser(user)
139
141