@stamhoofd/backend 2.15.0 → 2.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.template.json +2 -1
- package/index.ts +15 -1
- package/package.json +14 -12
- package/src/email-recipient-loaders/members.ts +61 -0
- package/src/endpoints/admin/memberships/GetChargeMembershipsSummaryEndpoint.ts +1 -1
- package/src/endpoints/admin/organizations/GetOrganizationsEndpoint.ts +5 -183
- package/src/endpoints/global/files/ExportToExcelEndpoint.ts +163 -0
- package/src/endpoints/global/files/GetFileCache.ts +69 -0
- package/src/endpoints/global/files/UploadFile.ts +4 -1
- package/src/endpoints/global/files/UploadImage.ts +14 -2
- package/src/endpoints/global/members/GetMembersEndpoint.ts +12 -299
- package/src/endpoints/organization/dashboard/email/EmailEndpoint.ts +22 -2
- package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.ts +6 -134
- package/src/endpoints/organization/dashboard/webshops/GetWebshopOrdersEndpoint.ts +5 -3
- package/src/endpoints/organization/dashboard/webshops/GetWebshopTicketsEndpoint.ts +5 -3
- package/src/excel-loaders/members.ts +101 -0
- package/src/excel-loaders/payments.ts +539 -0
- package/src/helpers/AdminPermissionChecker.ts +0 -3
- package/src/helpers/AuthenticatedStructures.ts +2 -0
- package/src/helpers/FileCache.ts +158 -0
- package/src/helpers/fetchToAsyncIterator.ts +34 -0
- package/src/sql-filters/balance-item-payments.ts +13 -0
- package/src/sql-filters/members.ts +179 -0
- package/src/sql-filters/organizations.ts +115 -0
- package/src/sql-filters/payments.ts +78 -0
- package/src/sql-filters/registrations.ts +24 -0
- package/src/sql-sorters/members.ts +46 -0
- package/src/sql-sorters/organizations.ts +71 -0
- package/src/sql-sorters/payments.ts +50 -0
- package/tsconfig.json +3 -4
- package/src/endpoints/organization/dashboard/payments/legacy/GetPaymentsEndpoint.ts +0 -170
package/.env.template.json
CHANGED
|
@@ -61,5 +61,6 @@
|
|
|
61
61
|
"NOLT_SSO_SECRET_KEY": "",
|
|
62
62
|
"INTERNAL_SECRET_KEY": "",
|
|
63
63
|
"CRONS_DISABLED": false,
|
|
64
|
-
"WHITELISTED_EMAIL_DESTINATIONS": ["*"]
|
|
64
|
+
"WHITELISTED_EMAIL_DESTINATIONS": ["*"],
|
|
65
|
+
"CACHE_PATH": "<fill in a safe path exlusive for Stamhoofd to store cached files>"
|
|
65
66
|
}
|
package/index.ts
CHANGED
|
@@ -9,8 +9,8 @@ import { Version } from '@stamhoofd/structures';
|
|
|
9
9
|
import { sleep } from "@stamhoofd/utility";
|
|
10
10
|
|
|
11
11
|
import { areCronsRunning, crons, stopCronScheduling } from './src/crons';
|
|
12
|
-
import { ContextMiddleware } from "./src/middleware/ContextMiddleware";
|
|
13
12
|
import { resumeEmails } from "./src/helpers/EmailResumer";
|
|
13
|
+
import { ContextMiddleware } from "./src/middleware/ContextMiddleware";
|
|
14
14
|
|
|
15
15
|
process.on("unhandledRejection", (error: Error) => {
|
|
16
16
|
console.error("unhandledRejection");
|
|
@@ -80,6 +80,13 @@ const start = async () => {
|
|
|
80
80
|
// Add CORS headers
|
|
81
81
|
routerServer.addResponseMiddleware(CORSMiddleware)
|
|
82
82
|
|
|
83
|
+
// Register Excel loaders
|
|
84
|
+
await import('./src/excel-loaders/members');
|
|
85
|
+
await import('./src/excel-loaders/payments');
|
|
86
|
+
|
|
87
|
+
// Register Email Recipient loaders
|
|
88
|
+
await import('./src/email-recipient-loaders/members');
|
|
89
|
+
|
|
83
90
|
routerServer.listen(STAMHOOFD.PORT ?? 9090);
|
|
84
91
|
|
|
85
92
|
resumeEmails().catch(console.error);
|
|
@@ -106,6 +113,13 @@ const start = async () => {
|
|
|
106
113
|
stopCronScheduling();
|
|
107
114
|
clearInterval(cronInterval)
|
|
108
115
|
|
|
116
|
+
if (STAMHOOFD.environment === 'development') {
|
|
117
|
+
setTimeout(() => {
|
|
118
|
+
console.error("Forcing exit after 5 seconds")
|
|
119
|
+
process.exit(1);
|
|
120
|
+
}, 5000);
|
|
121
|
+
}
|
|
122
|
+
|
|
109
123
|
try {
|
|
110
124
|
await routerServer.close()
|
|
111
125
|
console.log("HTTP server stopped");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stamhoofd/backend",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.16.0",
|
|
4
4
|
"main": "./dist/index.js",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
@@ -32,17 +32,19 @@
|
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
34
|
"@mollie/api-client": "3.7.0",
|
|
35
|
-
"@simonbackx/simple-database": "1.
|
|
36
|
-
"@simonbackx/simple-
|
|
35
|
+
"@simonbackx/simple-database": "1.25.0",
|
|
36
|
+
"@simonbackx/simple-encoding": "2.15.0",
|
|
37
|
+
"@simonbackx/simple-endpoints": "1.14.0",
|
|
37
38
|
"@simonbackx/simple-logging": "^1.0.1",
|
|
38
|
-
"@stamhoofd/backend-i18n": "^2.
|
|
39
|
-
"@stamhoofd/backend-middleware": "^2.
|
|
40
|
-
"@stamhoofd/email": "^2.
|
|
41
|
-
"@stamhoofd/models": "^2.
|
|
42
|
-
"@stamhoofd/queues": "^2.
|
|
43
|
-
"@stamhoofd/sql": "^2.
|
|
44
|
-
"@stamhoofd/structures": "^2.
|
|
45
|
-
"@stamhoofd/utility": "^2.
|
|
39
|
+
"@stamhoofd/backend-i18n": "^2.16.0",
|
|
40
|
+
"@stamhoofd/backend-middleware": "^2.16.0",
|
|
41
|
+
"@stamhoofd/email": "^2.16.0",
|
|
42
|
+
"@stamhoofd/models": "^2.16.0",
|
|
43
|
+
"@stamhoofd/queues": "^2.16.0",
|
|
44
|
+
"@stamhoofd/sql": "^2.16.0",
|
|
45
|
+
"@stamhoofd/structures": "^2.16.0",
|
|
46
|
+
"@stamhoofd/utility": "^2.16.0",
|
|
47
|
+
"archiver": "^7.0.1",
|
|
46
48
|
"aws-sdk": "^2.885.0",
|
|
47
49
|
"axios": "1.6.8",
|
|
48
50
|
"cookie": "^0.5.0",
|
|
@@ -58,5 +60,5 @@
|
|
|
58
60
|
"postmark": "4.0.2",
|
|
59
61
|
"stripe": "^16.6.0"
|
|
60
62
|
},
|
|
61
|
-
"gitHead": "
|
|
63
|
+
"gitHead": "d5f1a4dda2c1623058e96e8ff25d837b28e51bd6"
|
|
62
64
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Email } from "@stamhoofd/models";
|
|
2
|
+
import { SQL } from "@stamhoofd/sql";
|
|
3
|
+
import { EmailRecipientFilterType, LimitedFilteredRequest, PaginatedResponse, mergeFilters } from "@stamhoofd/structures";
|
|
4
|
+
import { GetMembersEndpoint } from "../endpoints/global/members/GetMembersEndpoint";
|
|
5
|
+
|
|
6
|
+
Email.recipientLoaders.set(EmailRecipientFilterType.Members, {
|
|
7
|
+
fetch: async (query: LimitedFilteredRequest) => {
|
|
8
|
+
const result = await GetMembersEndpoint.buildData(query)
|
|
9
|
+
|
|
10
|
+
return new PaginatedResponse({
|
|
11
|
+
results: result.results.members.flatMap(m => m.getEmailRecipients(['member'])),
|
|
12
|
+
next: result.next
|
|
13
|
+
});
|
|
14
|
+
},
|
|
15
|
+
|
|
16
|
+
count: async (query: LimitedFilteredRequest) => {
|
|
17
|
+
query.filter = mergeFilters([query.filter, {
|
|
18
|
+
'email': {
|
|
19
|
+
$neq: null
|
|
20
|
+
}
|
|
21
|
+
}])
|
|
22
|
+
const q = await GetMembersEndpoint.buildQuery(query)
|
|
23
|
+
return await q.count();
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
Email.recipientLoaders.set(EmailRecipientFilterType.MemberParents, {
|
|
28
|
+
fetch: async (query: LimitedFilteredRequest) => {
|
|
29
|
+
const result = await GetMembersEndpoint.buildData(query)
|
|
30
|
+
|
|
31
|
+
return new PaginatedResponse({
|
|
32
|
+
results: result.results.members.flatMap(m => m.getEmailRecipients(['parents'])),
|
|
33
|
+
next: result.next
|
|
34
|
+
});
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
count: async (query: LimitedFilteredRequest) => {
|
|
38
|
+
const q = await GetMembersEndpoint.buildQuery(query)
|
|
39
|
+
return await q.sum(
|
|
40
|
+
SQL.jsonLength(SQL.column('details'), '$.value.parents[*].email')
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
Email.recipientLoaders.set(EmailRecipientFilterType.MemberUnverified, {
|
|
46
|
+
fetch: async (query: LimitedFilteredRequest) => {
|
|
47
|
+
const result = await GetMembersEndpoint.buildData(query)
|
|
48
|
+
|
|
49
|
+
return new PaginatedResponse({
|
|
50
|
+
results: result.results.members.flatMap(m => m.getEmailRecipients(['unverified'])),
|
|
51
|
+
next: result.next
|
|
52
|
+
});
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
count: async (query: LimitedFilteredRequest) => {
|
|
56
|
+
const q = await GetMembersEndpoint.buildQuery(query)
|
|
57
|
+
return await q.sum(
|
|
58
|
+
SQL.jsonLength(SQL.column('details'), '$.value.unverifiedEmails')
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
|
|
2
|
-
import { SQL, SQLAlias,
|
|
2
|
+
import { SQL, SQLAlias, SQLCount, SQLDistinct, SQLSelectAs, SQLSum } from '@stamhoofd/sql';
|
|
3
3
|
import { ChargeMembershipsSummary, ChargeMembershipsTypeSummary } from '@stamhoofd/structures';
|
|
4
4
|
import { Context } from '../../../helpers/Context';
|
|
5
5
|
|
|
@@ -3,199 +3,21 @@ 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
5
|
import { Organization } from '@stamhoofd/models';
|
|
6
|
-
import { SQL,
|
|
6
|
+
import { SQL, compileToSQLFilter, compileToSQLSorter } from "@stamhoofd/sql";
|
|
7
7
|
import { CountFilteredRequest, LimitedFilteredRequest, Organization as OrganizationStruct, PaginatedResponse, PermissionLevel, StamhoofdFilter, getSortFilter } from '@stamhoofd/structures';
|
|
8
8
|
|
|
9
9
|
import { AuthenticatedStructures } from '../../../helpers/AuthenticatedStructures';
|
|
10
10
|
import { Context } from '../../../helpers/Context';
|
|
11
|
+
import { organizationFilterCompilers } from '../../../sql-filters/organizations';
|
|
12
|
+
import { organizationSorters } from '../../../sql-sorters/organizations';
|
|
11
13
|
|
|
12
14
|
type Params = Record<string, never>;
|
|
13
15
|
type Query = LimitedFilteredRequest;
|
|
14
16
|
type Body = undefined;
|
|
15
17
|
type ResponseBody = PaginatedResponse<OrganizationStruct[], LimitedFilteredRequest>
|
|
16
18
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
id: createSQLExpressionFilterCompiler(
|
|
20
|
-
SQL.column('organizations', 'id')
|
|
21
|
-
),
|
|
22
|
-
uri: createSQLExpressionFilterCompiler(
|
|
23
|
-
SQL.column('organizations', 'uri')
|
|
24
|
-
),
|
|
25
|
-
name: createSQLExpressionFilterCompiler(
|
|
26
|
-
SQL.column('organizations', 'name')
|
|
27
|
-
),
|
|
28
|
-
active: createSQLExpressionFilterCompiler(
|
|
29
|
-
SQL.column('organizations', 'active')
|
|
30
|
-
),
|
|
31
|
-
city: createSQLExpressionFilterCompiler(
|
|
32
|
-
SQL.jsonValue(SQL.column('organizations', 'address'), '$.value.city'),
|
|
33
|
-
{isJSONValue: true}
|
|
34
|
-
),
|
|
35
|
-
country: createSQLExpressionFilterCompiler(
|
|
36
|
-
SQL.jsonValue(SQL.column('organizations', 'address'), '$.value.country'),
|
|
37
|
-
{isJSONValue: true}
|
|
38
|
-
),
|
|
39
|
-
umbrellaOrganization: createSQLExpressionFilterCompiler(
|
|
40
|
-
SQL.jsonValue(SQL.column('organizations', 'meta'), '$.value.umbrellaOrganization'),
|
|
41
|
-
{isJSONValue: true}
|
|
42
|
-
),
|
|
43
|
-
type: createSQLExpressionFilterCompiler(
|
|
44
|
-
SQL.jsonValue(SQL.column('organizations', 'meta'), '$.value.type'),
|
|
45
|
-
{isJSONValue: true}
|
|
46
|
-
),
|
|
47
|
-
tags: createSQLExpressionFilterCompiler(
|
|
48
|
-
SQL.jsonValue(SQL.column('organizations', 'meta'), '$.value.tags'),
|
|
49
|
-
{isJSONValue: true, isJSONObject: true}
|
|
50
|
-
),
|
|
51
|
-
packages: createSQLRelationFilterCompiler(
|
|
52
|
-
SQL.select().from(
|
|
53
|
-
SQL.table('stamhoofd_packages')
|
|
54
|
-
).where(
|
|
55
|
-
SQL.column('organizationId'),
|
|
56
|
-
SQL.column('organizations', 'id'),
|
|
57
|
-
)
|
|
58
|
-
.andWhere(
|
|
59
|
-
SQL.column('validAt'),
|
|
60
|
-
SQLWhereSign.NotEqual,
|
|
61
|
-
new SQLNull()
|
|
62
|
-
).andWhere(
|
|
63
|
-
new SQLWhereOr([
|
|
64
|
-
new SQLWhereEqual(
|
|
65
|
-
SQL.column('validUntil'),
|
|
66
|
-
SQLWhereSign.Equal,
|
|
67
|
-
new SQLNull()
|
|
68
|
-
),
|
|
69
|
-
new SQLWhereEqual(
|
|
70
|
-
SQL.column('validUntil'),
|
|
71
|
-
SQLWhereSign.Greater,
|
|
72
|
-
new SQLNow()
|
|
73
|
-
)
|
|
74
|
-
])
|
|
75
|
-
).andWhere(
|
|
76
|
-
new SQLWhereOr([
|
|
77
|
-
new SQLWhereEqual(
|
|
78
|
-
SQL.column('removeAt'),
|
|
79
|
-
SQLWhereSign.Equal,
|
|
80
|
-
new SQLNull()
|
|
81
|
-
),
|
|
82
|
-
new SQLWhereEqual(
|
|
83
|
-
SQL.column('removeAt'),
|
|
84
|
-
SQLWhereSign.Greater,
|
|
85
|
-
new SQLNow()
|
|
86
|
-
)
|
|
87
|
-
])
|
|
88
|
-
),
|
|
89
|
-
|
|
90
|
-
// const pack1 = await STPackage.where({ organizationId, validAt: { sign: "!=", value: null }, removeAt: { sign: ">", value: new Date() }})
|
|
91
|
-
// const pack2 = await STPackage.where({ organizationId, validAt: { sign: "!=", value: null }, removeAt: null })
|
|
92
|
-
{
|
|
93
|
-
...baseSQLFilterCompilers,
|
|
94
|
-
"type": createSQLExpressionFilterCompiler(
|
|
95
|
-
SQL.jsonValue(SQL.column('meta'), '$.value.type'),
|
|
96
|
-
{isJSONValue: true}
|
|
97
|
-
)
|
|
98
|
-
}
|
|
99
|
-
),
|
|
100
|
-
members: createSQLRelationFilterCompiler(
|
|
101
|
-
SQL.select().from(
|
|
102
|
-
SQL.table('members')
|
|
103
|
-
).join(
|
|
104
|
-
SQL.join(
|
|
105
|
-
SQL.table('registrations')
|
|
106
|
-
).where(
|
|
107
|
-
SQL.column('members', 'id'),
|
|
108
|
-
SQL.column('registrations', 'memberId')
|
|
109
|
-
)
|
|
110
|
-
).where(
|
|
111
|
-
SQL.column('registrations', 'organizationId'),
|
|
112
|
-
SQL.column('organizations', 'id'),
|
|
113
|
-
),
|
|
114
|
-
|
|
115
|
-
{
|
|
116
|
-
...baseSQLFilterCompilers,
|
|
117
|
-
name: createSQLExpressionFilterCompiler(
|
|
118
|
-
new SQLConcat(
|
|
119
|
-
SQL.column('firstName'),
|
|
120
|
-
new SQLScalar(' '),
|
|
121
|
-
SQL.column('lastName'),
|
|
122
|
-
)
|
|
123
|
-
),
|
|
124
|
-
"firstName": createSQLColumnFilterCompiler('firstName'),
|
|
125
|
-
"lastName": createSQLColumnFilterCompiler('lastName'),
|
|
126
|
-
"email": createSQLColumnFilterCompiler('email')
|
|
127
|
-
}
|
|
128
|
-
),
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
const sorters: SQLSortDefinitions<Organization> = {
|
|
132
|
-
'id': {
|
|
133
|
-
getValue(a) {
|
|
134
|
-
return a.id
|
|
135
|
-
},
|
|
136
|
-
toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
|
|
137
|
-
return new SQLOrderBy({
|
|
138
|
-
column: SQL.column('id'),
|
|
139
|
-
direction
|
|
140
|
-
})
|
|
141
|
-
}
|
|
142
|
-
},
|
|
143
|
-
'name': {
|
|
144
|
-
getValue(a) {
|
|
145
|
-
return a.name
|
|
146
|
-
},
|
|
147
|
-
toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
|
|
148
|
-
return new SQLOrderBy({
|
|
149
|
-
column: SQL.column('name'),
|
|
150
|
-
direction
|
|
151
|
-
})
|
|
152
|
-
}
|
|
153
|
-
},
|
|
154
|
-
'uri': {
|
|
155
|
-
getValue(a) {
|
|
156
|
-
return a.uri
|
|
157
|
-
},
|
|
158
|
-
toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
|
|
159
|
-
return new SQLOrderBy({
|
|
160
|
-
column: SQL.column('uri'),
|
|
161
|
-
direction
|
|
162
|
-
})
|
|
163
|
-
}
|
|
164
|
-
},
|
|
165
|
-
'type': {
|
|
166
|
-
getValue(a) {
|
|
167
|
-
return a.meta.type
|
|
168
|
-
},
|
|
169
|
-
toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
|
|
170
|
-
return new SQLOrderBy({
|
|
171
|
-
column: SQL.jsonValue(SQL.column('meta'), '$.value.type'),
|
|
172
|
-
direction
|
|
173
|
-
})
|
|
174
|
-
}
|
|
175
|
-
},
|
|
176
|
-
'city': {
|
|
177
|
-
getValue(a) {
|
|
178
|
-
return a.address.city
|
|
179
|
-
},
|
|
180
|
-
toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
|
|
181
|
-
return new SQLOrderBy({
|
|
182
|
-
column: SQL.jsonValue(SQL.column('address'), '$.value.city'),
|
|
183
|
-
direction
|
|
184
|
-
})
|
|
185
|
-
}
|
|
186
|
-
},
|
|
187
|
-
'country': {
|
|
188
|
-
getValue(a) {
|
|
189
|
-
return a.address.country
|
|
190
|
-
},
|
|
191
|
-
toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
|
|
192
|
-
return new SQLOrderBy({
|
|
193
|
-
column: SQL.jsonValue(SQL.column('address'), '$.value.country'),
|
|
194
|
-
direction
|
|
195
|
-
})
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
}
|
|
19
|
+
const sorters = organizationSorters
|
|
20
|
+
const filterCompilers = organizationFilterCompilers
|
|
199
21
|
|
|
200
22
|
export class GetOrganizationsEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
|
|
201
23
|
queryDecoder = LimitedFilteredRequest as Decoder<LimitedFilteredRequest>
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-unsafe-argument */
|
|
2
|
+
import { Decoder, EncodableObject } from '@simonbackx/simple-encoding';
|
|
3
|
+
import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
|
|
4
|
+
import { SimpleError } from '@simonbackx/simple-errors';
|
|
5
|
+
import { Email } from '@stamhoofd/email';
|
|
6
|
+
import { ArchiverWriterAdapter, exportToExcel, XlsxTransformerSheet, XlsxWriter } from '@stamhoofd/excel-writer';
|
|
7
|
+
import { getEmailBuilderForTemplate, Platform, RateLimiter } from '@stamhoofd/models';
|
|
8
|
+
import { EmailTemplateType, ExcelExportRequest, ExcelExportResponse, ExcelExportType, LimitedFilteredRequest, PaginatedResponse, Recipient, Replacement } from '@stamhoofd/structures';
|
|
9
|
+
import { sleep } from "@stamhoofd/utility";
|
|
10
|
+
import { Context } from '../../../helpers/Context';
|
|
11
|
+
import { fetchToAsyncIterator } from '../../../helpers/fetchToAsyncIterator';
|
|
12
|
+
import { FileCache } from '../../../helpers/FileCache';
|
|
13
|
+
|
|
14
|
+
type Params = { type: string };
|
|
15
|
+
type Query = undefined;
|
|
16
|
+
type Body = ExcelExportRequest;
|
|
17
|
+
type ResponseBody = ExcelExportResponse;
|
|
18
|
+
|
|
19
|
+
type ExcelExporter<T extends EncodableObject> = {
|
|
20
|
+
fetch(request: LimitedFilteredRequest): Promise<PaginatedResponse<T[], LimitedFilteredRequest>>
|
|
21
|
+
sheets: XlsxTransformerSheet<T, unknown>[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const limiter = new RateLimiter({
|
|
25
|
+
limits: [
|
|
26
|
+
{
|
|
27
|
+
// Max 200 per day
|
|
28
|
+
limit: 200,
|
|
29
|
+
duration: 60 * 1000 * 60 * 24
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export class ExportToExcelEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
|
|
35
|
+
bodyDecoder = ExcelExportRequest as Decoder<ExcelExportRequest>
|
|
36
|
+
|
|
37
|
+
// Other endpoints can register exports here
|
|
38
|
+
static loaders: Map<ExcelExportType, ExcelExporter<EncodableObject>> = new Map()
|
|
39
|
+
|
|
40
|
+
protected doesMatch(request: Request): [true, Params] | [false] {
|
|
41
|
+
if (request.method != "POST") {
|
|
42
|
+
return [false];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const params = Endpoint.parseParameters(request.url, "/export/excel/@type", {type: String});
|
|
46
|
+
|
|
47
|
+
if (params) {
|
|
48
|
+
return [true, params as Params];
|
|
49
|
+
}
|
|
50
|
+
return [false];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async handle(request: DecodedRequest<Params, Query, Body>) {
|
|
54
|
+
const organization = await Context.setOptionalOrganizationScope();
|
|
55
|
+
const {user} = await Context.authenticate()
|
|
56
|
+
|
|
57
|
+
if (user.isApiUser) {
|
|
58
|
+
throw new SimpleError({
|
|
59
|
+
code: "not_allowed",
|
|
60
|
+
message: "API users are not allowed to export to Excel. The Excel export endpoint has a side effect of sending e-mails. Please use normal API endpoints to get the data you need.",
|
|
61
|
+
statusCode: 403
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const loader = ExportToExcelEndpoint.loaders.get(request.params.type as ExcelExportType);
|
|
66
|
+
|
|
67
|
+
if (!loader) {
|
|
68
|
+
throw new SimpleError({
|
|
69
|
+
code: "invalid_type",
|
|
70
|
+
message: "Invalid type " + request.params.type,
|
|
71
|
+
statusCode: 400
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
limiter.track(user.id, 1);
|
|
76
|
+
let sendEmail = false;
|
|
77
|
+
|
|
78
|
+
await Platform.getSharedStruct();
|
|
79
|
+
|
|
80
|
+
const result = await Promise.race([
|
|
81
|
+
this.job(loader, request.body, request.params.type).then(async (url: string) => {
|
|
82
|
+
if (sendEmail) {
|
|
83
|
+
const builder = await getEmailBuilderForTemplate(organization, {
|
|
84
|
+
template: {
|
|
85
|
+
type: EmailTemplateType.ExcelExportSucceeded
|
|
86
|
+
},
|
|
87
|
+
recipients: [
|
|
88
|
+
user.createRecipient(Replacement.create({
|
|
89
|
+
token: 'downloadUrl',
|
|
90
|
+
value: url
|
|
91
|
+
}))
|
|
92
|
+
],
|
|
93
|
+
from: Email.getInternalEmailFor(Context.i18n)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
if (builder) {
|
|
97
|
+
Email.schedule(builder)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return url;
|
|
103
|
+
}).catch(async (error) => {
|
|
104
|
+
if (sendEmail) {
|
|
105
|
+
const builder = await getEmailBuilderForTemplate(organization, {
|
|
106
|
+
template: {
|
|
107
|
+
type: EmailTemplateType.ExcelExportFailed
|
|
108
|
+
},
|
|
109
|
+
recipients: [
|
|
110
|
+
user.createRecipient()
|
|
111
|
+
],
|
|
112
|
+
from: Email.getInternalEmailFor(Context.i18n)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
if (builder) {
|
|
116
|
+
Email.schedule(builder)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
throw error
|
|
120
|
+
}),
|
|
121
|
+
sleep(3000)
|
|
122
|
+
])
|
|
123
|
+
|
|
124
|
+
if (typeof result === 'string') {
|
|
125
|
+
return new Response(ExcelExportResponse.create({
|
|
126
|
+
url: result
|
|
127
|
+
}))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// We'll send an e-mail
|
|
131
|
+
// Let the job know to send an e-mail when it is done
|
|
132
|
+
sendEmail = true;
|
|
133
|
+
|
|
134
|
+
return new Response(ExcelExportResponse.create({
|
|
135
|
+
url: null
|
|
136
|
+
}))
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async job(loader: ExcelExporter<EncodableObject>, request: ExcelExportRequest, type: string): Promise<string> {
|
|
140
|
+
// Estimate how long it will take.
|
|
141
|
+
// If too long, we'll schedule it and write it to Digitalocean Spaces
|
|
142
|
+
// Otherwise we'll just return the file directly
|
|
143
|
+
const {file, stream} = await FileCache.getWriteStream('.xlsx');
|
|
144
|
+
|
|
145
|
+
const zipWriterAdapter = new ArchiverWriterAdapter(stream);
|
|
146
|
+
const writer = new XlsxWriter(zipWriterAdapter);
|
|
147
|
+
|
|
148
|
+
// Limit to pages of 100
|
|
149
|
+
request.filter.limit = 100;
|
|
150
|
+
|
|
151
|
+
await exportToExcel({
|
|
152
|
+
definitions: loader.sheets,
|
|
153
|
+
writer,
|
|
154
|
+
dataGenerator: fetchToAsyncIterator(request.filter, loader),
|
|
155
|
+
filter: request.workbookFilter
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
console.log('Done writing excel file')
|
|
159
|
+
|
|
160
|
+
const url = 'https://'+ STAMHOOFD.domains.api + '/file-cache?file=' + encodeURIComponent(file) + '&name=' + encodeURIComponent(type)
|
|
161
|
+
return url;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-unsafe-argument */
|
|
2
|
+
import { AutoEncoder, Decoder, field, StringDecoder } from '@simonbackx/simple-encoding';
|
|
3
|
+
import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
|
|
4
|
+
import { Readable } from 'node:stream';
|
|
5
|
+
import { Context } from '../../../helpers/Context';
|
|
6
|
+
import { FileCache } from '../../../helpers/FileCache';
|
|
7
|
+
import { Formatter } from '@stamhoofd/utility';
|
|
8
|
+
import { RateLimiter } from '@stamhoofd/models';
|
|
9
|
+
|
|
10
|
+
type Params = Record<never, never>;
|
|
11
|
+
class Query extends AutoEncoder {
|
|
12
|
+
@field({ decoder: StringDecoder})
|
|
13
|
+
file: string
|
|
14
|
+
|
|
15
|
+
@field({ decoder: StringDecoder, optional: true })
|
|
16
|
+
name?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type Body = undefined;
|
|
20
|
+
type ResponseBody = Readable;
|
|
21
|
+
|
|
22
|
+
export const limiter = new RateLimiter({
|
|
23
|
+
limits: [
|
|
24
|
+
{
|
|
25
|
+
// Max 200 per day
|
|
26
|
+
limit: 200,
|
|
27
|
+
duration: 60 * 1000 * 60 * 24
|
|
28
|
+
}
|
|
29
|
+
]
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export class GetFileCache extends Endpoint<Params, Query, Body, ResponseBody> {
|
|
33
|
+
queryDecoder = Query as Decoder<Query>
|
|
34
|
+
|
|
35
|
+
protected doesMatch(request: Request): [true, Params] | [false] {
|
|
36
|
+
if (request.method != "GET") {
|
|
37
|
+
return [false];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const params = Endpoint.parseParameters(request.url, "/file-cache", {});
|
|
41
|
+
|
|
42
|
+
if (params) {
|
|
43
|
+
return [true, params as Params];
|
|
44
|
+
}
|
|
45
|
+
return [false];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async handle(request: DecodedRequest<Params, Query, Body>) {
|
|
49
|
+
await Context.setOptionalOrganizationScope();
|
|
50
|
+
|
|
51
|
+
limiter.track(request.request.getIP(), 1);
|
|
52
|
+
|
|
53
|
+
// Return readable stream
|
|
54
|
+
const {stream, contentLength, extension} = await FileCache.read(request.query.file, 1);
|
|
55
|
+
|
|
56
|
+
const response = new Response(stream);
|
|
57
|
+
response.headers['Content-Type'] = 'application/octet-stream';
|
|
58
|
+
|
|
59
|
+
if (request.query.name) {
|
|
60
|
+
const slug = Formatter.fileSlug(request.query.name) + extension;
|
|
61
|
+
response.headers['Content-Disposition'] = `attachment; filename="${slug}"`;
|
|
62
|
+
} else {
|
|
63
|
+
response.headers['Content-Disposition'] = `attachment; filename="bestand${extension}"`;
|
|
64
|
+
}
|
|
65
|
+
response.headers['Content-Length'] = contentLength.toString();
|
|
66
|
+
|
|
67
|
+
return response;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -9,6 +9,7 @@ import { promises as fs } from "fs";
|
|
|
9
9
|
import { v4 as uuidv4 } from "uuid";
|
|
10
10
|
|
|
11
11
|
import { Context } from '../../../helpers/Context';
|
|
12
|
+
import { limiter } from './UploadImage';
|
|
12
13
|
|
|
13
14
|
type Params = Record<string, never>;
|
|
14
15
|
type Query = {};
|
|
@@ -50,7 +51,7 @@ export class UploadFile extends Endpoint<Params, Query, Body, ResponseBody> {
|
|
|
50
51
|
|
|
51
52
|
async handle(request: DecodedRequest<Params, Query, Body>) {
|
|
52
53
|
await Context.setOptionalOrganizationScope()
|
|
53
|
-
await Context.authenticate();
|
|
54
|
+
const {user} = await Context.authenticate();
|
|
54
55
|
|
|
55
56
|
if (!Context.auth.canUpload()) {
|
|
56
57
|
throw Context.auth.error()
|
|
@@ -68,6 +69,8 @@ export class UploadFile extends Endpoint<Params, Query, Body, ResponseBody> {
|
|
|
68
69
|
throw new Error("Not supported without real request")
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
limiter.track(user.id, 1);
|
|
73
|
+
|
|
71
74
|
const form = formidable({ maxFileSize: 20 * 1024 * 1024, maxFields: 1, keepExtensions: true });
|
|
72
75
|
const file = await new Promise<FormidableFile>((resolve, reject) => {
|
|
73
76
|
if (!request.request.request) {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { Decoder, ObjectData } 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 { Image } from '@stamhoofd/models';
|
|
5
|
+
import { Image, RateLimiter } from '@stamhoofd/models';
|
|
6
6
|
import { Image as ImageStruct, ResolutionRequest } from '@stamhoofd/structures';
|
|
7
7
|
import formidable from 'formidable';
|
|
8
8
|
import { promises as fs } from "fs";
|
|
@@ -31,6 +31,16 @@ interface FormidableFile {
|
|
|
31
31
|
mimetype: string | null;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
export const limiter = new RateLimiter({
|
|
35
|
+
limits: [
|
|
36
|
+
{
|
|
37
|
+
// Max 50 per hour
|
|
38
|
+
limit: 50,
|
|
39
|
+
duration: 60 * 1000 * 60
|
|
40
|
+
}
|
|
41
|
+
]
|
|
42
|
+
});
|
|
43
|
+
|
|
34
44
|
export class UploadImage extends Endpoint<Params, Query, Body, ResponseBody> {
|
|
35
45
|
protected doesMatch(request: Request): [true, Params] | [false] {
|
|
36
46
|
if (request.method != "POST") {
|
|
@@ -48,7 +58,7 @@ export class UploadImage extends Endpoint<Params, Query, Body, ResponseBody> {
|
|
|
48
58
|
|
|
49
59
|
async handle(request: DecodedRequest<Params, Query, Body>) {
|
|
50
60
|
await Context.setOptionalOrganizationScope()
|
|
51
|
-
await Context.authenticate();
|
|
61
|
+
const {user} = await Context.authenticate();
|
|
52
62
|
|
|
53
63
|
if (!Context.auth.canUpload()) {
|
|
54
64
|
throw Context.auth.error()
|
|
@@ -66,6 +76,8 @@ export class UploadImage extends Endpoint<Params, Query, Body, ResponseBody> {
|
|
|
66
76
|
throw new Error("Not supported without real request")
|
|
67
77
|
}
|
|
68
78
|
|
|
79
|
+
limiter.track(user.id, 1);
|
|
80
|
+
|
|
69
81
|
const form = formidable({
|
|
70
82
|
maxFileSize: 5 * 1024 * 1024,
|
|
71
83
|
keepExtensions: true,
|