@stamhoofd/backend 2.17.3 → 2.18.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.
@@ -0,0 +1,210 @@
1
+ import {
2
+ Organization,
3
+ OrganizationRegistrationPeriod,
4
+ Platform,
5
+ } from "@stamhoofd/models";
6
+ import { QueueHandler } from "@stamhoofd/queues";
7
+ import {
8
+ PlatformPremiseType,
9
+ Platform as PlatformStruct,
10
+ SetupStepType,
11
+ SetupSteps,
12
+ } from "@stamhoofd/structures";
13
+
14
+ type SetupStepOperation = (setupSteps: SetupSteps, organization: Organization, platform: PlatformStruct) => void;
15
+
16
+ export class SetupStepUpdater {
17
+ private static readonly STEP_TYPE_OPERATIONS: Record<
18
+ SetupStepType,
19
+ SetupStepOperation
20
+ > = {
21
+ [SetupStepType.Groups]: this.updateStepGroups,
22
+ [SetupStepType.Premises]: this.updateStepPremises,
23
+ };
24
+
25
+ static async updateSetupStepsForAllOrganizationsInCurrentPeriod({
26
+ batchSize, premiseTypes
27
+ }: { batchSize?: number, premiseTypes?: PlatformPremiseType[] } = {}) {
28
+ const tag = "updateSetupStepsForAllOrganizationsInCurrentPeriod";
29
+ QueueHandler.cancel(tag);
30
+
31
+ await QueueHandler.schedule(tag, async () => {
32
+ const platform = (await Platform.getSharedPrivateStruct()).clone();
33
+ if(premiseTypes) {
34
+ platform.config.premiseTypes = premiseTypes;
35
+ }
36
+ const periodId = platform.period.id;
37
+
38
+ let lastId = "";
39
+
40
+ while (true) {
41
+ const organizationRegistrationPeriods =
42
+ await OrganizationRegistrationPeriod.where(
43
+ {
44
+ id: { sign: ">", value: lastId },
45
+ periodId: periodId,
46
+ },
47
+ { limit: batchSize ?? 10, sort: ["id"] }
48
+ );
49
+
50
+ if (organizationRegistrationPeriods.length === 0) {
51
+ lastId = "";
52
+ break;
53
+ }
54
+
55
+ const organizationPeriodMap = new Map(
56
+ organizationRegistrationPeriods.map((period) => {
57
+ return [period.organizationId, period];
58
+ })
59
+ );
60
+
61
+ const organizations = await Organization.getByIDs(
62
+ ...organizationPeriodMap.keys()
63
+ );
64
+
65
+ for (const organization of organizations) {
66
+ const organizationId = organization.id;
67
+ const organizationRegistrationPeriod =
68
+ organizationPeriodMap.get(organizationId);
69
+
70
+ if (!organizationRegistrationPeriod) {
71
+ console.error(
72
+ `[FLAG-MOMENT] organizationRegistrationPeriod not found for organization with id ${organizationId}`
73
+ );
74
+ continue;
75
+ }
76
+
77
+ console.log(
78
+ "[FLAG-MOMENT] checking flag moments for " +
79
+ organizationId
80
+ );
81
+
82
+ await SetupStepUpdater.updateFor(
83
+ organizationRegistrationPeriod,
84
+ platform,
85
+ organization
86
+ );
87
+ }
88
+
89
+ lastId =
90
+ organizationRegistrationPeriods[
91
+ organizationRegistrationPeriods.length - 1
92
+ ].id;
93
+ }
94
+ });
95
+ }
96
+
97
+ static async updateForOrganization(
98
+ organization: Organization,
99
+ {
100
+ platform,
101
+ organizationRegistrationPeriod,
102
+ }: {
103
+ platform?: PlatformStruct;
104
+ organizationRegistrationPeriod?: OrganizationRegistrationPeriod;
105
+ } = {}
106
+ ) {
107
+ if (!platform) {
108
+ platform = await Platform.getSharedPrivateStruct();
109
+ if (!platform) {
110
+ console.error("No platform not found");
111
+ return;
112
+ }
113
+ }
114
+
115
+ if (!organizationRegistrationPeriod) {
116
+ const periodId = platform.period.id;
117
+ organizationRegistrationPeriod = (
118
+ await OrganizationRegistrationPeriod.where({
119
+ organizationId: organization.id,
120
+ periodId: periodId,
121
+ })
122
+ )[0];
123
+
124
+ if (!organizationRegistrationPeriod) {
125
+ console.error(
126
+ `OrganizationRegistrationPeriod with organizationId ${organization.id} and periodId ${periodId} not found`
127
+ );
128
+ return;
129
+ }
130
+ }
131
+
132
+ await this.updateFor(
133
+ organizationRegistrationPeriod,
134
+ platform,
135
+ organization
136
+ );
137
+ }
138
+
139
+ static async updateFor(
140
+ organizationRegistrationPeriod: OrganizationRegistrationPeriod,
141
+ platform: PlatformStruct,
142
+ organization: Organization
143
+ ) {
144
+ const setupSteps = organizationRegistrationPeriod.setupSteps;
145
+
146
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
147
+ for (const stepType of Object.values(SetupStepType)) {
148
+ console.log(`[STEP TYPE] ${stepType}`);
149
+ const operation = this.STEP_TYPE_OPERATIONS[stepType];
150
+ operation(setupSteps, organization, platform);
151
+ }
152
+
153
+ await organizationRegistrationPeriod.save();
154
+ }
155
+
156
+ static updateStepPremises(
157
+ setupSteps: SetupSteps,
158
+ organization: Organization,
159
+ platform: PlatformStruct
160
+ ) {
161
+ let totalSteps = 0;
162
+ let finishedSteps = 0;
163
+
164
+ const premiseTypes = platform.config.premiseTypes;
165
+
166
+ for (const premiseType of premiseTypes) {
167
+ const { min, max } = premiseType;
168
+ if (min === null && max === null) {
169
+ continue;
170
+ }
171
+
172
+ totalSteps++;
173
+
174
+ const premiseTypeId = premiseType.id;
175
+ let totalPremisesOfThisType = 0;
176
+
177
+ for (const premise of organization.privateMeta.premises) {
178
+ if (premise.premiseTypeIds.includes(premiseTypeId)) {
179
+ totalPremisesOfThisType++;
180
+ }
181
+ }
182
+
183
+ if (max !== null && totalPremisesOfThisType > max) {
184
+ continue;
185
+ }
186
+
187
+ if (min !== null && totalPremisesOfThisType < min) {
188
+ continue;
189
+ }
190
+
191
+ finishedSteps++;
192
+ }
193
+
194
+ setupSteps.update(SetupStepType.Premises, {
195
+ totalSteps,
196
+ finishedSteps,
197
+ });
198
+ }
199
+
200
+ static updateStepGroups(
201
+ setupSteps: SetupSteps,
202
+ _organization: Organization,
203
+ _platform: PlatformStruct
204
+ ) {
205
+ setupSteps.update(SetupStepType.Groups, {
206
+ totalSteps: 0,
207
+ finishedSteps: 0,
208
+ });
209
+ }
210
+ }
@@ -0,0 +1,16 @@
1
+ import { Migration } from '@simonbackx/simple-database';
2
+ import { SetupStepUpdater } from '../helpers/SetupStepsUpdater';
3
+
4
+ export default new Migration(async () => {
5
+ if (STAMHOOFD.environment == "test") {
6
+ console.log("skipped in tests")
7
+ return;
8
+ }
9
+
10
+ if(STAMHOOFD.userMode !== "platform") {
11
+ console.log("skipped seed setup-steps because usermode not platform")
12
+ return;
13
+ }
14
+
15
+ await SetupStepUpdater.updateSetupStepsForAllOrganizationsInCurrentPeriod();
16
+ })
@@ -9,6 +9,8 @@ import { registrationFilterCompilers } from "./registrations";
9
9
  export const memberFilterCompilers: SQLFilterDefinitions = {
10
10
  ...baseSQLFilterCompilers,
11
11
  id: createSQLColumnFilterCompiler('id'),
12
+ firstName: createSQLColumnFilterCompiler('firstName'),
13
+ lastName: createSQLColumnFilterCompiler('lastName'),
12
14
  name: createSQLExpressionFilterCompiler(
13
15
  new SQLConcat(
14
16
  SQL.column('firstName'),
@@ -3,6 +3,14 @@ import { SQL, SQLOrderBy, SQLOrderByDirection, SQLSortDefinitions } from "@stamh
3
3
  import { Formatter } from "@stamhoofd/utility"
4
4
 
5
5
  export const memberSorters: SQLSortDefinitions<MemberWithRegistrations> = {
6
+ // WARNING! TEST NEW SORTERS THOROUGHLY!
7
+ // Try to avoid creating sorters on fields that er not 1:1 with the database, that often causes pagination issues if not thought through
8
+ // An example: sorting on 'name' is not a good idea, because it is a concatenation of two fields.
9
+ // You might be tempted to use ORDER BY firstName, lastName, but that will not work as expected and it needs to be ORDER BY CONCAT(firstName, ' ', lastName)
10
+ // Why? Because ORDER BY firstName, lastName produces a different order dan ORDER BY CONCAT(firstName, ' ', lastName) if there are multiple people with spaces in the first name
11
+ // And that again causes issues with pagination because the next query will append a filter of name > 'John Doe' - causing duplicate and/or skipped results
12
+ // What if you need mapping? simply map the sorters in the frontend: name -> firstname, lastname, age -> birthDay, etc.
13
+
6
14
  'id': {
7
15
  getValue(a) {
8
16
  return a.id
@@ -14,22 +22,26 @@ export const memberSorters: SQLSortDefinitions<MemberWithRegistrations> = {
14
22
  })
15
23
  }
16
24
  },
17
- 'name': {
25
+ 'firstName': {
26
+ getValue(a) {
27
+ return a.firstName
28
+ },
29
+ toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
30
+ return new SQLOrderBy({
31
+ column: SQL.column('firstName'),
32
+ direction
33
+ })
34
+ }
35
+ },
36
+ 'lastName': {
18
37
  getValue(a) {
19
- // Note: we should not use 'name' here (that will remove the in between space if one is missing), because we need to use Exactly the same value as the filter will use
20
- return a.firstName + ' ' + a.lastName
38
+ return a.lastName
21
39
  },
22
40
  toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
23
- return SQLOrderBy.combine([
24
- new SQLOrderBy({
25
- column: SQL.column('firstName'),
26
- direction
27
- }),
28
- new SQLOrderBy({
29
- column: SQL.column('lastName'),
30
- direction
31
- })
32
- ])
41
+ return new SQLOrderBy({
42
+ column: SQL.column('lastName'),
43
+ direction
44
+ })
33
45
  }
34
46
  },
35
47
  'birthDay': {
@@ -43,5 +55,4 @@ export const memberSorters: SQLSortDefinitions<MemberWithRegistrations> = {
43
55
  })
44
56
  }
45
57
  }
46
- // Note: never add mapped sortings, that should happen in the frontend -> e.g. map age to birthDay
47
58
  }
@@ -2,6 +2,14 @@ import { Organization } from "@stamhoofd/models"
2
2
  import { SQL, SQLOrderBy, SQLOrderByDirection, SQLSortDefinitions } from "@stamhoofd/sql"
3
3
 
4
4
  export const organizationSorters: SQLSortDefinitions<Organization> = {
5
+ // WARNING! TEST NEW SORTERS THOROUGHLY!
6
+ // Try to avoid creating sorters on fields that er not 1:1 with the database, that often causes pagination issues if not thought through
7
+ // An example: sorting on 'name' is not a good idea, because it is a concatenation of two fields.
8
+ // You might be tempted to use ORDER BY firstName, lastName, but that will not work as expected and it needs to be ORDER BY CONCAT(firstName, ' ', lastName)
9
+ // Why? Because ORDER BY firstName, lastName produces a different order dan ORDER BY CONCAT(firstName, ' ', lastName) if there are multiple people with spaces in the first name
10
+ // And that again causes issues with pagination because the next query will append a filter of name > 'John Doe' - causing duplicate and/or skipped results
11
+ // What if you need mapping? simply map the sorters in the frontend: name -> firstname, lastname, age -> birthDay, etc.
12
+
5
13
  'id': {
6
14
  getValue(a) {
7
15
  return a.id
@@ -3,6 +3,14 @@ import { SQL, SQLOrderBy, SQLOrderByDirection, SQLSortDefinitions } from "@stamh
3
3
  import { Formatter } from "@stamhoofd/utility"
4
4
 
5
5
  export const paymentSorters: SQLSortDefinitions<Payment> = {
6
+ // WARNING! TEST NEW SORTERS THOROUGHLY!
7
+ // Try to avoid creating sorters on fields that er not 1:1 with the database, that often causes pagination issues if not thought through
8
+ // An example: sorting on 'name' is not a good idea, because it is a concatenation of two fields.
9
+ // You might be tempted to use ORDER BY firstName, lastName, but that will not work as expected and it needs to be ORDER BY CONCAT(firstName, ' ', lastName)
10
+ // Why? Because ORDER BY firstName, lastName produces a different order dan ORDER BY CONCAT(firstName, ' ', lastName) if there are multiple people with spaces in the first name
11
+ // And that again causes issues with pagination because the next query will append a filter of name > 'John Doe' - causing duplicate and/or skipped results
12
+ // What if you need mapping? simply map the sorters in the frontend: name -> firstname, lastname, age -> birthDay, etc.
13
+
6
14
  'id': {
7
15
  getValue(a) {
8
16
  return a.id
@@ -1,47 +0,0 @@
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
- }
@@ -1,185 +0,0 @@
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 ? organization.getBaseStructure() : 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
- }