@stamhoofd/backend 2.37.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stamhoofd/backend",
3
- "version": "2.37.0",
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.37.0",
40
- "@stamhoofd/backend-middleware": "2.37.0",
41
- "@stamhoofd/email": "2.37.0",
42
- "@stamhoofd/models": "2.37.0",
43
- "@stamhoofd/queues": "2.37.0",
44
- "@stamhoofd/sql": "2.37.0",
45
- "@stamhoofd/structures": "2.37.0",
46
- "@stamhoofd/utility": "2.37.0",
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": "c01fc82d46188cc4e35a7edb90ee595ff7129e6f"
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"
@@ -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;
@@ -73,6 +75,7 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
73
75
  }
74
76
 
75
77
  const updateMembershipMemberIds = new Set<string>()
78
+ const updateMembershipsForOrganizations = new Set<string>()
76
79
 
77
80
  // Loop all members one by one
78
81
  for (const put of request.body.getPuts()) {
@@ -470,7 +473,7 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
470
473
  })
471
474
  }
472
475
 
473
- if (!membership.canDelete()) {
476
+ if (!membership.canDelete() && !Context.auth.hasPlatformFullAccess()) {
474
477
  throw new SimpleError({
475
478
  code: "invalid_field",
476
479
  message: "Invalid invoice",
@@ -478,8 +481,8 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
478
481
  })
479
482
  }
480
483
 
481
- membership.deletedAt = new Date()
482
- await membership.save()
484
+ await membership.doDelete();
485
+ updateMembershipsForOrganizations.add(membership.organizationId) // can influence free memberships in other members of same organization
483
486
  updateMembershipMemberIds.add(member.id)
484
487
  }
485
488
 
@@ -496,6 +499,14 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
496
499
  }
497
500
  }
498
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
+
499
510
  if(shouldUpdateSetupSteps && organization) {
500
511
  SetupStepUpdater.updateForOrganization(organization).catch(console.error);
501
512
  }
@@ -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) {
@@ -37,12 +37,22 @@ export class PatchOrganizationEndpoint extends Endpoint<Params, Query, Body, Res
37
37
  }
38
38
 
39
39
  async handle(request: DecodedRequest<Params, Query, Body>) {
40
- const organization = await Context.setOrganizationScope();
41
- const {user} = await Context.authenticate()
40
+ const organization = await Context.setOrganizationScope({allowInactive: true});
41
+ await Context.authenticate()
42
42
 
43
43
  if (!await Context.auth.hasSomeAccess(organization.id)) {
44
44
  throw Context.auth.error()
45
45
  }
46
+
47
+ if (!organization.active && !Context.auth.hasPlatformFullAccess()) {
48
+ throw new SimpleError({
49
+ code: "permission_denied",
50
+ message: "You do not have permissions to edit an inactive organization",
51
+ human: 'Je hebt geen toegangsrechten om een inactieve groep te bewerken',
52
+ statusCode: 403
53
+ })
54
+
55
+ }
46
56
 
47
57
  // check if organization ID matches
48
58
  if (request.body.id !== organization.id) {
@@ -295,6 +305,13 @@ export class PatchOrganizationEndpoint extends Endpoint<Params, Query, Body, Res
295
305
  }
296
306
  }
297
307
 
308
+ if (request.body.active !== undefined) {
309
+ if (!Context.auth.hasPlatformFullAccess()) {
310
+ throw Context.auth.error('Enkel een platform hoofdbeheerder kan een groep (in)actief maken')
311
+ }
312
+ organization.active = request.body.active;
313
+ }
314
+
298
315
  if (request.body.uri) {
299
316
  if (!Context.auth.hasPlatformFullAccess()) {
300
317
  throw Context.auth.error()
@@ -46,7 +46,7 @@ export class ExchangePaymentEndpoint extends Endpoint<Params, Query, Body, Respo
46
46
  }
47
47
 
48
48
  async handle(request: DecodedRequest<Params, Query, Body>) {
49
- const organization = await Context.setOrganizationScope()
49
+ const organization = await Context.setOptionalOrganizationScope()
50
50
  if (!request.query.exchange) {
51
51
  await Context.authenticate()
52
52
  }
@@ -152,7 +152,7 @@ export class ExchangePaymentEndpoint extends Endpoint<Params, Query, Body, Respo
152
152
  /**
153
153
  * ID of payment is needed because of race conditions (need to fetch payment in a race condition save queue)
154
154
  */
155
- static async pollStatus(paymentId: string, organization: Organization, cancel = false): Promise<Payment | undefined> {
155
+ static async pollStatus(paymentId: string, org: Organization|null, cancel = false): Promise<Payment | undefined> {
156
156
  // Prevent polling the same payment multiple times at the same time: create a queue to prevent races
157
157
  QueueHandler.cancel("payments/"+paymentId); // Prevent creating more than one queue item for the same payment
158
158
  return await QueueHandler.schedule("payments/"+paymentId, async () => {
@@ -162,6 +162,17 @@ export class ExchangePaymentEndpoint extends Endpoint<Params, Query, Body, Respo
162
162
  return
163
163
  }
164
164
 
165
+ if (!payment.organizationId) {
166
+ console.error('Payment without organization not supported', payment.id)
167
+ return
168
+ }
169
+
170
+ const organization = org ?? await Organization.getByID(payment.organizationId)
171
+ if (!organization) {
172
+ console.error('Organization not found for payment', payment.id)
173
+ return
174
+ }
175
+
165
176
  const testMode = organization.privateMeta.useTestPayments ?? STAMHOOFD.environment != 'production'
166
177
 
167
178
  if (payment.status == PaymentStatus.Pending || payment.status == PaymentStatus.Created || (payment.provider === PaymentProvider.Buckaroo && payment.status == PaymentStatus.Failed)) {
@@ -26,7 +26,7 @@ export class GetPaymentEndpoint extends Endpoint<Params, Query, Body, ResponseBo
26
26
  }
27
27
 
28
28
  async handle(request: DecodedRequest<Params, Query, Body>) {
29
- await Context.setOrganizationScope()
29
+ await Context.setOptionalOrganizationScope()
30
30
  await Context.authenticate()
31
31
 
32
32
  const payment = await Payment.getByID(request.params.id);
@@ -1,16 +1,22 @@
1
1
  import { XlsxBuiltInNumberFormat } from "@stamhoofd/excel-writer";
2
- import { ExcelExportType, Gender, LimitedFilteredRequest, MemberWithRegistrationsBlob, PaginatedResponse, Platform } from "@stamhoofd/structures";
2
+ import { Platform } from "@stamhoofd/models";
3
+ import { ExcelExportType, Gender, GroupType, LimitedFilteredRequest, MemberWithRegistrationsBlob, PlatformFamily, PlatformMember, UnencodeablePaginatedResponse, Platform as PlatformStruct } from "@stamhoofd/structures";
3
4
  import { ExportToExcelEndpoint } from "../endpoints/global/files/ExportToExcelEndpoint";
4
5
  import { GetMembersEndpoint } from "../endpoints/global/members/GetMembersEndpoint";
5
6
  import { Context } from "../helpers/Context";
6
7
  import { XlsxTransformerColumnHelper } from "../helpers/xlsxAddressTransformerColumnFactory";
8
+ import { Formatter } from "@stamhoofd/utility";
9
+ import { AuthenticatedStructures } from "../helpers/AuthenticatedStructures";
7
10
 
8
11
  ExportToExcelEndpoint.loaders.set(ExcelExportType.Members, {
9
12
  fetch: async (query: LimitedFilteredRequest) => {
10
13
  const result = await GetMembersEndpoint.buildData(query)
11
14
 
12
- return new PaginatedResponse({
13
- results: result.results.members,
15
+ return new UnencodeablePaginatedResponse({
16
+ results: PlatformFamily.createSingles(result.results, {
17
+ contextOrganization: Context.organization ? (await AuthenticatedStructures.organization(Context.organization)) : null,
18
+ platform: await Platform.getSharedStruct()
19
+ }),
14
20
  next: result.next
15
21
  });
16
22
  },
@@ -23,7 +29,7 @@ ExportToExcelEndpoint.loaders.set(ExcelExportType.Members, {
23
29
  id: 'id',
24
30
  name: 'ID',
25
31
  width: 20,
26
- getValue: (object: MemberWithRegistrationsBlob) => ({
32
+ getValue: ({patchedMember: object}: PlatformMember) => ({
27
33
  value: object.id
28
34
  })
29
35
  },
@@ -31,7 +37,7 @@ ExportToExcelEndpoint.loaders.set(ExcelExportType.Members, {
31
37
  id: 'memberNumber',
32
38
  name: 'Nummer',
33
39
  width: 20,
34
- getValue: (object: MemberWithRegistrationsBlob) => ({
40
+ getValue: ({patchedMember: object}: PlatformMember) => ({
35
41
  value: object.details.memberNumber
36
42
  })
37
43
  },
@@ -39,7 +45,7 @@ ExportToExcelEndpoint.loaders.set(ExcelExportType.Members, {
39
45
  id: 'firstName',
40
46
  name: 'Voornaam',
41
47
  width: 20,
42
- getValue: (object: MemberWithRegistrationsBlob) => ({
48
+ getValue: ({patchedMember: object}: PlatformMember) => ({
43
49
  value: object.details.firstName
44
50
  })
45
51
  },
@@ -47,7 +53,7 @@ ExportToExcelEndpoint.loaders.set(ExcelExportType.Members, {
47
53
  id: 'lastName',
48
54
  name: 'Achternaam',
49
55
  width: 20,
50
- getValue: (object: MemberWithRegistrationsBlob) => ({
56
+ getValue: ({patchedMember: object}: PlatformMember) => ({
51
57
  value: object.details.lastName
52
58
  })
53
59
  },
@@ -55,7 +61,7 @@ ExportToExcelEndpoint.loaders.set(ExcelExportType.Members, {
55
61
  id: 'birthDay',
56
62
  name: 'Geboortedatum',
57
63
  width: 20,
58
- getValue: (object: MemberWithRegistrationsBlob) => ({
64
+ getValue: ({patchedMember: object}: PlatformMember) => ({
59
65
  value: object.details.birthDay,
60
66
  style: {
61
67
  numberFormat: {
@@ -68,7 +74,7 @@ ExportToExcelEndpoint.loaders.set(ExcelExportType.Members, {
68
74
  id: 'age',
69
75
  name: 'Leeftijd',
70
76
  width: 20,
71
- getValue: (object: MemberWithRegistrationsBlob) => ({
77
+ getValue: ({patchedMember: object}: PlatformMember) => ({
72
78
  value: object.details.age,
73
79
  })
74
80
  },
@@ -76,7 +82,7 @@ ExportToExcelEndpoint.loaders.set(ExcelExportType.Members, {
76
82
  id: 'gender',
77
83
  name: 'Geslacht',
78
84
  width: 20,
79
- getValue: (object: MemberWithRegistrationsBlob) => {
85
+ getValue: ({patchedMember: object}: PlatformMember) => {
80
86
  const gender = object.details.gender;
81
87
 
82
88
  return ({
@@ -88,7 +94,7 @@ ExportToExcelEndpoint.loaders.set(ExcelExportType.Members, {
88
94
  id: 'phone',
89
95
  name: 'Telefoonnummer',
90
96
  width: 20,
91
- getValue: (object: MemberWithRegistrationsBlob) => ({
97
+ getValue: ({patchedMember: object}: PlatformMember) => ({
92
98
  value: object.details.phone,
93
99
  })
94
100
  },
@@ -96,14 +102,14 @@ ExportToExcelEndpoint.loaders.set(ExcelExportType.Members, {
96
102
  id: 'email',
97
103
  name: 'E-mailadres',
98
104
  width: 20,
99
- getValue: (object: MemberWithRegistrationsBlob) => ({
105
+ getValue: ({patchedMember: object}: PlatformMember) => ({
100
106
  value: object.details.email,
101
107
  })
102
108
  },
103
- XlsxTransformerColumnHelper.createAddressColumns<MemberWithRegistrationsBlob>({
109
+ XlsxTransformerColumnHelper.createAddressColumns<PlatformMember>({
104
110
  matchId: 'address',
105
111
  identifier: 'Adres',
106
- getAddress: (object) => {
112
+ getAddress: ({patchedMember: object}: PlatformMember) => {
107
113
  // get member address if exists
108
114
  const memberAddress = object.details.address;
109
115
  if(memberAddress) {
@@ -124,7 +130,7 @@ ExportToExcelEndpoint.loaders.set(ExcelExportType.Members, {
124
130
  id: 'securityCode',
125
131
  name: 'Beveiligingscode',
126
132
  width: 20,
127
- getValue: (object: MemberWithRegistrationsBlob) => ({
133
+ getValue: ({patchedMember: object}: PlatformMember) => ({
128
134
  value: object.details.securityCode,
129
135
  })
130
136
  },
@@ -132,7 +138,7 @@ ExportToExcelEndpoint.loaders.set(ExcelExportType.Members, {
132
138
  id: 'uitpasNumber',
133
139
  name: 'UiTPAS-nummer',
134
140
  width: 20,
135
- getValue: (object: MemberWithRegistrationsBlob) => ({
141
+ getValue: ({patchedMember: object}: PlatformMember) => ({
136
142
  value: object.details.uitpasNumber,
137
143
  })
138
144
  },
@@ -141,7 +147,7 @@ ExportToExcelEndpoint.loaders.set(ExcelExportType.Members, {
141
147
  // todo: use correct term
142
148
  name: 'Financiële ondersteuning',
143
149
  width: 20,
144
- getValue: (object: MemberWithRegistrationsBlob) => ({
150
+ getValue: ({patchedMember: object}: PlatformMember) => ({
145
151
  value: XlsxTransformerColumnHelper.formatBoolean(object.details.requiresFinancialSupport?.value),
146
152
  })
147
153
  },
@@ -149,11 +155,69 @@ ExportToExcelEndpoint.loaders.set(ExcelExportType.Members, {
149
155
  id: 'notes',
150
156
  name: 'Notities',
151
157
  width: 20,
152
- getValue: (object: MemberWithRegistrationsBlob) => ({
158
+ getValue: ({patchedMember: object}: PlatformMember) => ({
153
159
  value: object.details.notes,
154
160
  })
155
161
  },
156
162
 
163
+ {
164
+ id: 'organization',
165
+ name: 'Groep',
166
+ width: 40,
167
+ getValue: (member: PlatformMember) => {
168
+ const organizations = member.filterOrganizations({currentPeriod: true, types: [GroupType.Membership]})
169
+ const str = Formatter.joinLast(organizations.map(o => o.name).sort(), ', ', ' en ') || Context.i18n.$t('1a16a32a-7ee4-455d-af3d-6073821efa8f')
170
+
171
+ return {
172
+ value: str
173
+ }
174
+ }
175
+ },
176
+
177
+ {
178
+ id: 'uri',
179
+ name: 'Groepsnummer',
180
+ width: 30,
181
+ getValue: (member: PlatformMember) => {
182
+ const organizations = member.filterOrganizations({currentPeriod: true, types: [GroupType.Membership]})
183
+ const str = Formatter.joinLast(organizations.map(o => o.uri).sort(), ', ', ' en ') || Context.i18n.$t('1a16a32a-7ee4-455d-af3d-6073821efa8f')
184
+
185
+ return {
186
+ value: str
187
+ }
188
+ }
189
+ },
190
+
191
+ {
192
+ id: 'group',
193
+ name: 'Leeftijdsgroep',
194
+ width: 40,
195
+ getValue: (member: PlatformMember) => {
196
+ const groups = member.filterRegistrations({currentPeriod: true, types: [GroupType.Membership], organizationId: Context.organization?.id})
197
+ const str = Formatter.joinLast(Formatter.uniqueArray(groups.map(o => o.group.settings.name)).sort(), ', ', ' en ') || Context.i18n.$t('1a16a32a-7ee4-455d-af3d-6073821efa8f')
198
+
199
+ return {
200
+ value: str
201
+ }
202
+ }
203
+ },
204
+
205
+ {
206
+ id: 'defaultAgeGroup',
207
+ name: 'Standaard leeftijdsgroep',
208
+ width: 40,
209
+ getValue: (member: PlatformMember) => {
210
+ const groups = member.filterRegistrations({currentPeriod: true, types: [GroupType.Membership], organizationId: Context.organization?.id})
211
+ const defaultAgeGroupIds = Formatter.uniqueArray(groups.filter(o => o.group.defaultAgeGroupId))
212
+ const defaultAgeGroups = defaultAgeGroupIds.map(o => PlatformStruct.shared.config.defaultAgeGroups.find(g => g.id === o.group.defaultAgeGroupId)?.name ?? 'verwijderde leeftijdsgroep')
213
+ const str = Formatter.joinLast(Formatter.uniqueArray(defaultAgeGroups).sort(), ', ', ' en ') || Context.i18n.$t('1a16a32a-7ee4-455d-af3d-6073821efa8f')
214
+
215
+ return {
216
+ value: str
217
+ }
218
+ }
219
+ },
220
+
157
221
  ...XlsxTransformerColumnHelper.creatColumnsForParents(),
158
222
 
159
223
  // unverified data
@@ -161,7 +225,7 @@ ExportToExcelEndpoint.loaders.set(ExcelExportType.Members, {
161
225
  id: 'unverifiedPhones',
162
226
  name: 'Niet-geverifieerde telefoonnummers',
163
227
  width: 20,
164
- getValue: (object: MemberWithRegistrationsBlob) => ({
228
+ getValue: ({patchedMember: object}: PlatformMember) => ({
165
229
  value: object.details.unverifiedPhones.join(', '),
166
230
  })
167
231
  },
@@ -169,7 +233,7 @@ ExportToExcelEndpoint.loaders.set(ExcelExportType.Members, {
169
233
  id: 'unverifiedEmails',
170
234
  name: 'Niet-geverifieerde e-mailadressen',
171
235
  width: 20,
172
- getValue: (object: MemberWithRegistrationsBlob) => ({
236
+ getValue: ({patchedMember: object}: PlatformMember) => ({
173
237
  value: object.details.unverifiedEmails.join(', '),
174
238
  })
175
239
  },
@@ -183,7 +247,7 @@ ExportToExcelEndpoint.loaders.set(ExcelExportType.Members, {
183
247
  id: 'unverifiedAddresses',
184
248
  name: 'Niet-geverifieerde adressen',
185
249
  width: 20,
186
- getValue: (object: MemberWithRegistrationsBlob) => ({
250
+ getValue: ({patchedMember: object}: PlatformMember) => ({
187
251
  value: object.details.unverifiedAddresses.map(a => a.toString()).join('; '),
188
252
  })
189
253
  },
@@ -191,9 +255,8 @@ ExportToExcelEndpoint.loaders.set(ExcelExportType.Members, {
191
255
  // Dynamic records
192
256
  {
193
257
  match(id) {
194
- console.log('match', id)
195
258
  if (id.startsWith('recordAnswers.')) {
196
- const platform = Platform.shared
259
+ const platform = PlatformStruct.shared
197
260
  const organization = Context.organization
198
261
 
199
262
  const recordSettings = [
@@ -219,7 +282,7 @@ ExportToExcelEndpoint.loaders.set(ExcelExportType.Members, {
219
282
  id: `recordAnswers.${recordSettingId}.${index}`,
220
283
  name: columnName,
221
284
  width: 20,
222
- getValue: (object: MemberWithRegistrationsBlob) => ({
285
+ getValue: ({patchedMember: object}: PlatformMember) => ({
223
286
  value: object.details.recordAnswers.get(recordSettingId)?.excelValues[index]?.value ?? ''
224
287
  })
225
288
  }
@@ -133,8 +133,8 @@ export class ContextInstance {
133
133
  return await this.setOrganizationScope()
134
134
  }
135
135
 
136
- async setOrganizationScope() {
137
- const organization = await Organization.fromApiHost(this.request.host);
136
+ async setOrganizationScope(options?: {allowInactive?: boolean}) {
137
+ const organization = await Organization.fromApiHost(this.request.host, options);
138
138
 
139
139
  this.organization = organization
140
140
  this.i18n.switchToLocale({ country: organization.address.country })
@@ -27,6 +27,7 @@ export const MembershipCharger = {
27
27
 
28
28
  let createdCount = 0;
29
29
  let createdPrice = 0;
30
+ const chunkSize = 100;
30
31
 
31
32
  // eslint-disable-next-line no-constant-condition
32
33
  while (true) {
@@ -35,7 +36,7 @@ export const MembershipCharger = {
35
36
  .where('balanceItemId', null)
36
37
  .where('deletedAt', null)
37
38
  .whereNot('organizationId', chargeVia)
38
- .limit(100)
39
+ .limit(chunkSize)
39
40
  .orderBy(
40
41
  new SQLOrderBy({
41
42
  column: SQL.column('id'),
@@ -57,6 +58,7 @@ export const MembershipCharger = {
57
58
  if (membership.balanceItemId) {
58
59
  continue;
59
60
  }
61
+
60
62
  const type = getType(membership.membershipTypeId);
61
63
  if (!type) {
62
64
  console.error('Unknown membership type id ', membership.membershipTypeId)
@@ -74,6 +76,14 @@ export const MembershipCharger = {
74
76
  continue;
75
77
  }
76
78
 
79
+ // Force price update (required because could have changed - especially for free memberships in combination with deletes)
80
+ try {
81
+ await membership.calculatePrice(member)
82
+ } catch (e) {
83
+ console.error('Failed to update price for membership. Not charged.', membership.id, e)
84
+ continue;
85
+ }
86
+
77
87
  const balanceItem = new BalanceItem();
78
88
  balanceItem.unitPrice = membership.price
79
89
  balanceItem.amount = 1
@@ -101,6 +111,7 @@ export const MembershipCharger = {
101
111
 
102
112
  await balanceItem.save();
103
113
  membership.balanceItemId = balanceItem.id;
114
+ membership.maximumFreeAmount = membership.freeAmount;
104
115
  await membership.save()
105
116
 
106
117
  createdBalanceItems.push(balanceItem)
@@ -111,7 +122,7 @@ export const MembershipCharger = {
111
122
 
112
123
  await BalanceItem.updateOutstanding(createdBalanceItems)
113
124
 
114
- if (memberships.length < 100) {
125
+ if (memberships.length < chunkSize) {
115
126
  break;
116
127
  }
117
128
 
@@ -124,5 +135,76 @@ export const MembershipCharger = {
124
135
  }
125
136
 
126
137
  console.log('Charged ' + Formatter.integer(createdCount) +' memberships, for a total value of ' + Formatter.price(createdPrice))
138
+ },
139
+
140
+ async updatePrices(organizationId?: string) {
141
+ console.log('Update prices...')
142
+
143
+ // Loop all
144
+ let lastId = "";
145
+ let createdCount = 0;
146
+ const chunkSize = 100;
147
+
148
+ // eslint-disable-next-line no-constant-condition
149
+ while (true) {
150
+ const q = MemberPlatformMembership.select()
151
+ .where('id', SQLWhereSign.Greater, lastId)
152
+ .where('balanceItemId', null)
153
+ .where('deletedAt', null);
154
+
155
+ if (organizationId) {
156
+ q.where('organizationId', organizationId)
157
+ }
158
+
159
+ const memberships = await q
160
+ .limit(chunkSize)
161
+ .orderBy(
162
+ new SQLOrderBy({
163
+ column: SQL.column('id'),
164
+ direction: 'ASC'
165
+ })
166
+ )
167
+ .fetch();
168
+
169
+ if (memberships.length === 0) {
170
+ break;
171
+ }
172
+
173
+ const memberIds = Formatter.uniqueArray(memberships.map(m => m.memberId))
174
+ const members = await Member.getByIDs(...memberIds)
175
+
176
+ for (const membership of memberships) {
177
+ const member = members.find(m => m.id === membership.memberId)
178
+
179
+ if (!member) {
180
+ console.error('Unexpected missing member id ', membership.memberId, 'for membership', membership.id)
181
+ continue;
182
+ }
183
+
184
+ // Force price update (required because could have changed - especially for free memberships in combination with deletes)
185
+ try {
186
+ await membership.calculatePrice(member)
187
+ await membership.save()
188
+ } catch (e) {
189
+ console.error('Failed to update price for membership', membership.id, e)
190
+ continue;
191
+ }
192
+ console.log('Updated price for membership', membership.id, membership.price)
193
+ createdCount += 1;
194
+ }
195
+
196
+ if (memberships.length < chunkSize) {
197
+ break;
198
+ }
199
+
200
+ const z = lastId;
201
+ lastId = memberships[memberships.length - 1].id;
202
+
203
+ if (lastId === z) {
204
+ throw new Error('Unexpected infinite loop found in MembershipCharger')
205
+ }
206
+ }
207
+
208
+ console.log('Updated prices of ' + Formatter.integer(createdCount) +' memberships')
127
209
  }
128
210
  };
@@ -1,10 +1,9 @@
1
- import { EncodableObject } from "@simonbackx/simple-encoding";
2
- import { LimitedFilteredRequest, PaginatedResponse } from "@stamhoofd/structures";
1
+ import { IPaginatedResponse, LimitedFilteredRequest } from "@stamhoofd/structures";
3
2
 
4
- export function fetchToAsyncIterator<T extends EncodableObject>(
3
+ export function fetchToAsyncIterator<T>(
5
4
  initialFilter: LimitedFilteredRequest,
6
5
  loader: {
7
- fetch(request: LimitedFilteredRequest): Promise<PaginatedResponse<T, LimitedFilteredRequest>>
6
+ fetch(request: LimitedFilteredRequest): Promise<IPaginatedResponse<T, LimitedFilteredRequest>>
8
7
  }
9
8
  ): AsyncIterable<T> {
10
9
  return {