@stamhoofd/backend 2.73.3 → 2.75.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/index.ts +7 -2
- package/package.json +13 -13
- package/src/audit-logs/MemberPlatformMembershipLogger.ts +1 -1
- package/src/crons/update-cached-balances.ts +1 -2
- package/src/endpoints/admin/organizations/GetOrganizationsEndpoint.ts +2 -2
- package/src/endpoints/auth/CreateAdminEndpoint.ts +4 -15
- package/src/endpoints/global/audit-logs/GetAuditLogsEndpoint.ts +2 -2
- package/src/endpoints/global/events/GetEventNotificationsCountEndpoint.ts +43 -0
- package/src/endpoints/global/events/GetEventNotificationsEndpoint.ts +181 -0
- package/src/endpoints/global/events/GetEventsEndpoint.ts +2 -2
- package/src/endpoints/global/events/PatchEventNotificationsEndpoint.ts +288 -0
- package/src/endpoints/global/events/PatchEventsEndpoint.ts +2 -2
- package/src/endpoints/global/files/UploadFile.ts +56 -4
- package/src/endpoints/global/files/UploadImage.ts +9 -3
- package/src/endpoints/global/members/GetMembersEndpoint.ts +2 -2
- package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +14 -5
- package/src/endpoints/global/platform/GetPlatformAdminsEndpoint.ts +1 -5
- package/src/endpoints/global/platform/PatchPlatformEnpoint.ts +7 -0
- package/src/endpoints/global/registration/GetUserDocumentsEndpoint.ts +1 -1
- package/src/endpoints/global/registration/RegisterMembersEndpoint.test.ts +1756 -164
- package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +2 -2
- package/src/endpoints/global/registration-periods/PatchRegistrationPeriodsEndpoint.ts +48 -2
- package/src/endpoints/organization/dashboard/documents/GetDocumentsEndpoint.ts +2 -2
- package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +1 -1
- package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.ts +2 -2
- package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalancesEndpoint.ts +2 -2
- package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.ts +8 -0
- package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.ts +3 -3
- package/src/endpoints/organization/dashboard/webshops/GetWebshopOrdersEndpoint.ts +2 -2
- package/src/endpoints/organization/dashboard/webshops/GetWebshopTicketsEndpoint.ts +2 -2
- package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +1 -2
- package/src/helpers/AdminPermissionChecker.ts +80 -2
- package/src/helpers/AuthenticatedStructures.ts +88 -2
- package/src/helpers/FlagMomentCleanup.ts +1 -8
- package/src/helpers/GlobalHelper.ts +15 -0
- package/src/helpers/MembershipCharger.ts +2 -1
- package/src/seeds-temporary/README.md +1 -0
- package/src/services/EventNotificationService.ts +201 -0
- package/src/services/FileSignService.ts +227 -0
- package/src/services/PlatformMembershipService.ts +38 -14
- package/src/sql-filters/event-notifications.ts +39 -0
- package/src/sql-filters/organizations.ts +1 -1
- package/src/sql-sorters/event-notifications.ts +96 -0
- package/src/sql-sorters/events.ts +2 -2
- package/src/sql-sorters/organizations.ts +2 -2
- package/tests/e2e/private-files.test.ts +497 -0
- package/tests/e2e/register.test.ts +762 -0
- package/tests/helpers/TestServer.ts +3 -0
- package/tests/jest.setup.ts +15 -2
- package/tsconfig.json +1 -0
- /package/src/{seeds → seeds-temporary}/1732117645-move-rrn.ts +0 -0
- /package/src/{seeds → seeds-temporary}/1736266448-recall-balance-item-price-paid.ts +0 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { SimpleError } from '@simonbackx/simple-errors';
|
|
2
|
+
import { EventNotification, Member, MemberResponsibilityRecord, Organization, Platform, sendEmailTemplate, User } from '@stamhoofd/models';
|
|
3
|
+
import { EmailTemplateType, PermissionLevel, Recipient, RecordCategory, Replacement } from '@stamhoofd/structures';
|
|
4
|
+
import { Formatter } from '@stamhoofd/utility';
|
|
5
|
+
import { AdminPermissionChecker } from '../helpers/AdminPermissionChecker';
|
|
6
|
+
import { Context } from '../helpers/Context';
|
|
7
|
+
import { AuthenticatedStructures } from '../helpers/AuthenticatedStructures';
|
|
8
|
+
|
|
9
|
+
export class EventNotificationService {
|
|
10
|
+
static async validateType(notification: EventNotification) {
|
|
11
|
+
const platform = await Platform.getSharedPrivateStruct();
|
|
12
|
+
const type = platform.config.eventNotificationTypes.find(t => t.id === notification.typeId);
|
|
13
|
+
|
|
14
|
+
if (!type) {
|
|
15
|
+
throw new SimpleError({
|
|
16
|
+
code: 'invalid_field',
|
|
17
|
+
message: 'Invalid type',
|
|
18
|
+
human: Context.i18n.$t('4d8be2b1-559a-4c16-a76f-67a8ba85de7f'),
|
|
19
|
+
field: 'typeId',
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return type;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
static async cleanAnswers(notification: EventNotification) {
|
|
27
|
+
const type = await this.validateType(notification);
|
|
28
|
+
const struct = await AuthenticatedStructures.eventNotification(notification);
|
|
29
|
+
const patchedStruct = RecordCategory.removeOldAnswers(type.recordCategories, struct);
|
|
30
|
+
notification.recordAnswers = patchedStruct.getRecordAnswers();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
static async getSubmitterRecipients(notification: EventNotification): Promise<Recipient[]> {
|
|
34
|
+
// Send the email to all users with full permissions + the submitter + the creator
|
|
35
|
+
const type = await this.validateType(notification);
|
|
36
|
+
const responsibilityIds = type.contactResponsibilityIds;
|
|
37
|
+
const organizationId = notification.organizationId;
|
|
38
|
+
const platform = await Platform.getSharedPrivateStruct();
|
|
39
|
+
|
|
40
|
+
const recipients: Recipient[] = [];
|
|
41
|
+
|
|
42
|
+
if (responsibilityIds.length) {
|
|
43
|
+
// Query all users with the responsibility
|
|
44
|
+
const records = await MemberResponsibilityRecord.select()
|
|
45
|
+
.where('organizationId', organizationId)
|
|
46
|
+
.andWhere('responsibilityId', responsibilityIds)
|
|
47
|
+
.andWhere(MemberResponsibilityRecord.whereActive)
|
|
48
|
+
.fetch();
|
|
49
|
+
const memberIds = records.map(r => r.memberId);
|
|
50
|
+
const members = await Member.getByIDs(...memberIds);
|
|
51
|
+
|
|
52
|
+
for (const member of members) {
|
|
53
|
+
if (!member.details.email) {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
recipients.push(Recipient.create({
|
|
57
|
+
firstName: member.details.firstName,
|
|
58
|
+
lastName: member.details.lastName,
|
|
59
|
+
email: member.details.email,
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (notification.submittedBy) {
|
|
65
|
+
const user = await User.getByID(notification.submittedBy);
|
|
66
|
+
if (user && user.verified) {
|
|
67
|
+
const p = new AdminPermissionChecker(user, platform);
|
|
68
|
+
|
|
69
|
+
if (await p.canAccessEventNotification(notification, PermissionLevel.Write)) {
|
|
70
|
+
recipients.push(Recipient.create({
|
|
71
|
+
firstName: user.firstName,
|
|
72
|
+
lastName: user.lastName,
|
|
73
|
+
email: user.email,
|
|
74
|
+
}));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (notification.createdBy) {
|
|
80
|
+
const user = await User.getByID(notification.createdBy);
|
|
81
|
+
if (user && user.verified) {
|
|
82
|
+
const p = new AdminPermissionChecker(user, platform);
|
|
83
|
+
|
|
84
|
+
if (await p.canAccessEventNotification(notification, PermissionLevel.Write)) {
|
|
85
|
+
recipients.push(Recipient.create({
|
|
86
|
+
firstName: user.firstName,
|
|
87
|
+
lastName: user.lastName,
|
|
88
|
+
email: user.email,
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Remove duplicates
|
|
95
|
+
const emails = new Set<string>();
|
|
96
|
+
const filteredRecipients: Recipient[] = [];
|
|
97
|
+
for (const recipient of recipients) {
|
|
98
|
+
if (!emails.has(recipient.email)) {
|
|
99
|
+
emails.add(recipient.email);
|
|
100
|
+
filteredRecipients.push(recipient);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return filteredRecipients;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
static async getReviewerRecipients(notification: EventNotification): Promise<Recipient[]> {
|
|
108
|
+
// Find all users that have permission to review this notification
|
|
109
|
+
const platformAdmins = await User.getPlatformAdmins();
|
|
110
|
+
const recipients: Recipient[] = [];
|
|
111
|
+
const platform = await Platform.getSharedPrivateStruct();
|
|
112
|
+
|
|
113
|
+
for (const user of platformAdmins) {
|
|
114
|
+
const p = new AdminPermissionChecker(user, platform);
|
|
115
|
+
|
|
116
|
+
if (await p.canReviewEventNotification(notification)) {
|
|
117
|
+
recipients.push(Recipient.create({
|
|
118
|
+
firstName: user.firstName,
|
|
119
|
+
lastName: user.lastName,
|
|
120
|
+
email: user.email,
|
|
121
|
+
}));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return recipients;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
static async getEmailReplacements(notification: EventNotification, forReviewers = false) {
|
|
129
|
+
const organization = await Organization.getByID(notification.organizationId);
|
|
130
|
+
if (!organization) {
|
|
131
|
+
throw new Error('Organization not found');
|
|
132
|
+
}
|
|
133
|
+
const events = EventNotification.events.isLoaded(notification) ? notification.events : await EventNotification.events.load(notification);
|
|
134
|
+
const type = await this.validateType(notification);
|
|
135
|
+
let submitterName = 'Anoniem';
|
|
136
|
+
|
|
137
|
+
if (notification.submittedBy) {
|
|
138
|
+
const user = await User.getByID(notification.submittedBy);
|
|
139
|
+
if (user) {
|
|
140
|
+
submitterName = user.name ?? user.email;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return [
|
|
145
|
+
Replacement.create({
|
|
146
|
+
token: 'eventName',
|
|
147
|
+
value: events.map(e => e.name).join(', '),
|
|
148
|
+
}),
|
|
149
|
+
Replacement.create({
|
|
150
|
+
token: 'organizationName',
|
|
151
|
+
value: organization.name,
|
|
152
|
+
}),
|
|
153
|
+
Replacement.create({
|
|
154
|
+
token: 'reviewUrl',
|
|
155
|
+
value: forReviewers ? Context.i18n.localizedDomains.adminUrl + '/kampmeldingen/' + encodeURIComponent(notification.id) : (events.length === 0 ? organization.getBaseStructure().dashboardUrl : (organization.getBaseStructure().dashboardUrl + '/activiteiten/' + events[0].id + '/' + Formatter.slug(type.title))),
|
|
156
|
+
}),
|
|
157
|
+
Replacement.create({
|
|
158
|
+
token: 'dateRange',
|
|
159
|
+
value: Formatter.dateRange(notification.startDate, notification.endDate, undefined, false),
|
|
160
|
+
}),
|
|
161
|
+
Replacement.create({
|
|
162
|
+
token: 'submitterName',
|
|
163
|
+
value: submitterName,
|
|
164
|
+
}),
|
|
165
|
+
Replacement.create({
|
|
166
|
+
token: 'feedbackText',
|
|
167
|
+
html: notification.feedbackText ? `<p class="pre-wrap"><em>${Formatter.escapeHtml(notification.feedbackText)}</em></p>` : `<p class="pre-wrap"><em>${Formatter.escapeHtml($t('4c3149b3-e02a-4071-bf21-941711e0238d'))}</em></p>`,
|
|
168
|
+
}),
|
|
169
|
+
];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
static async sendSubmitterEmail(type: EmailTemplateType, notification: EventNotification) {
|
|
173
|
+
if (notification.endDate < new Date()) {
|
|
174
|
+
// Ignore
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
await sendEmailTemplate(null, {
|
|
178
|
+
recipients: await this.getSubmitterRecipients(notification),
|
|
179
|
+
template: {
|
|
180
|
+
type,
|
|
181
|
+
},
|
|
182
|
+
defaultReplacements: await this.getEmailReplacements(notification),
|
|
183
|
+
type: 'transactional',
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
static async sendReviewerEmail(type: EmailTemplateType, notification: EventNotification) {
|
|
188
|
+
if (notification.endDate < new Date()) {
|
|
189
|
+
// Ignore
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
await sendEmailTemplate(null, {
|
|
193
|
+
recipients: await this.getReviewerRecipients(notification),
|
|
194
|
+
template: {
|
|
195
|
+
type,
|
|
196
|
+
},
|
|
197
|
+
defaultReplacements: await this.getEmailReplacements(notification, true),
|
|
198
|
+
type: 'transactional',
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
};
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { DecodedRequest, Request, Response } from '@simonbackx/simple-endpoints';
|
|
2
|
+
import { SimpleError } from '@simonbackx/simple-errors';
|
|
3
|
+
import { File } from '@stamhoofd/structures';
|
|
4
|
+
import AWS from 'aws-sdk';
|
|
5
|
+
import * as jose from 'jose';
|
|
6
|
+
import chalk from 'chalk';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* This service creates signed urls for valid files
|
|
10
|
+
*/
|
|
11
|
+
export class FileSignService {
|
|
12
|
+
static s3 = new AWS.S3({
|
|
13
|
+
endpoint: STAMHOOFD.SPACES_ENDPOINT,
|
|
14
|
+
accessKeyId: STAMHOOFD.SPACES_KEY,
|
|
15
|
+
secretAccessKey: STAMHOOFD.SPACES_SECRET,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
static async load() {
|
|
19
|
+
/**
|
|
20
|
+
* Note the algorithm is only used for signing. For verification the algorithm inside the public keys are used
|
|
21
|
+
*/
|
|
22
|
+
const alg = STAMHOOFD.FILE_SIGNING_ALG || 'ES256';
|
|
23
|
+
|
|
24
|
+
if (!STAMHOOFD.FILE_SIGNING_PUBLIC_KEY || !STAMHOOFD.FILE_SIGNING_PRIVATE_KEY) {
|
|
25
|
+
if (STAMHOOFD.environment !== 'development') {
|
|
26
|
+
throw new Error('Missing environment variables for file signing. Please make sure FILE_SIGNING_PUBLIC_KEY, FILE_SIGNING_PRIVATE_KEY and FILE_SIGNING_ALG are set.');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
console.warn(chalk.yellow('File signing is disabled because the environment variables are not set. Please make sure FILE_SIGNING_PUBLIC_KEY, FILE_SIGNING_PRIVATE_KEY and FILE_SIGNING_ALG are set.'));
|
|
30
|
+
|
|
31
|
+
const { publicKey, privateKey } = await jose.generateKeyPair(alg);
|
|
32
|
+
|
|
33
|
+
const exportedPublicKey = await jose.exportJWK(publicKey);
|
|
34
|
+
const exportedPrivateKey = await jose.exportJWK(privateKey);
|
|
35
|
+
|
|
36
|
+
console.log('Example keys you can use in your development environment:');
|
|
37
|
+
console.log('FILE_SIGNING_PUBLIC_KEY:', JSON.stringify(exportedPublicKey));
|
|
38
|
+
console.log('FILE_SIGNING_ALG:', JSON.stringify(exportedPrivateKey));
|
|
39
|
+
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const privateKey = await jose.importJWK(STAMHOOFD.FILE_SIGNING_PRIVATE_KEY!);
|
|
44
|
+
|
|
45
|
+
// Support for multiple public keys (in case we need to rotate keys)
|
|
46
|
+
const jwks = jose.createLocalJWKSet({
|
|
47
|
+
keys: [
|
|
48
|
+
STAMHOOFD.FILE_SIGNING_PUBLIC_KEY!,
|
|
49
|
+
],
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
File.verifyFile = async (file) => {
|
|
53
|
+
if (!file.signature) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const { payload, protectedHeader } = await jose.compactVerify(file.signature, jwks);
|
|
59
|
+
|
|
60
|
+
// Check if payload matches the file
|
|
61
|
+
const decoded = (new TextDecoder().decode(payload));
|
|
62
|
+
if (decoded !== file.signPayload) {
|
|
63
|
+
console.error('Invalid payload:', decoded);
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
catch (e) {
|
|
70
|
+
if (e instanceof jose.errors.JWSSignatureVerificationFailed) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
console.error('Failed to verify file:', e);
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
File.signFile = async (file) => {
|
|
79
|
+
if (!STAMHOOFD.FILE_SIGNING_PRIVATE_KEY) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const jws = await new jose.CompactSign(
|
|
83
|
+
new TextEncoder().encode(file.signPayload),
|
|
84
|
+
)
|
|
85
|
+
.setProtectedHeader({ alg })
|
|
86
|
+
.sign(privateKey);
|
|
87
|
+
|
|
88
|
+
file.signature = jws;
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
static async fillSignedUrl(file: File, duration = 60 * 60) {
|
|
93
|
+
if (!file.isPrivate) {
|
|
94
|
+
if (file.signedUrl) {
|
|
95
|
+
console.error('Warning: public file has a signed url');
|
|
96
|
+
// this will not be encoded because of the file encode implementation
|
|
97
|
+
}
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Only created signed urls for files that were generated by our own server
|
|
102
|
+
if (!await file.verify()) {
|
|
103
|
+
console.error('Failed to verify file:', file.id);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
console.log('Signing file:', file.id);
|
|
108
|
+
|
|
109
|
+
if (file.signedUrl) {
|
|
110
|
+
console.error('Warning: file already signed');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
const url = await this.s3.getSignedUrlPromise('getObject', {
|
|
115
|
+
Bucket: STAMHOOFD.SPACES_BUCKET,
|
|
116
|
+
Key: file.path,
|
|
117
|
+
Expires: duration,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
console.log('Signed url:', url);
|
|
121
|
+
|
|
122
|
+
return new File({
|
|
123
|
+
...file,
|
|
124
|
+
signedUrl: url,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
catch (e) {
|
|
128
|
+
console.error('Failed to sign file:', e);
|
|
129
|
+
return file;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
static async fillSignedUrlsForStruct(data: any) {
|
|
134
|
+
if (data instanceof File) {
|
|
135
|
+
return await this.fillSignedUrl(data);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (Array.isArray(data)) {
|
|
139
|
+
for (let i = 0; i < data.length; i++) {
|
|
140
|
+
const value = data[i];
|
|
141
|
+
const r = await this.fillSignedUrlsForStruct(value);
|
|
142
|
+
if (r !== undefined) {
|
|
143
|
+
data[i] = r;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (data instanceof Map) {
|
|
150
|
+
for (const [key, value] of data.entries()) {
|
|
151
|
+
const r = await this.fillSignedUrlsForStruct(value);
|
|
152
|
+
|
|
153
|
+
if (r !== undefined) {
|
|
154
|
+
data.set(key, r);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Loop all keys and search for File objects + replace them with the signed variant
|
|
162
|
+
if (typeof data === 'object' && data !== null) {
|
|
163
|
+
for (const key in data) {
|
|
164
|
+
const r = await this.fillSignedUrlsForStruct(data[key]);
|
|
165
|
+
if (r !== undefined) {
|
|
166
|
+
data[key] = r;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
static async verifyFilesInStruct(data: any) {
|
|
174
|
+
if (data instanceof File) {
|
|
175
|
+
if (!data.isPrivate) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Clear all incoming signed urls
|
|
180
|
+
data.signedUrl = null;
|
|
181
|
+
|
|
182
|
+
if (!await data.verify()) {
|
|
183
|
+
// This is the first level of defence - it prevents the server from storing untrusted files
|
|
184
|
+
// but in the case this check is bypassed, we still need to check the signature when creating signed urls
|
|
185
|
+
throw new SimpleError({
|
|
186
|
+
code: 'invalid_signature',
|
|
187
|
+
message: 'Invalid signature for file',
|
|
188
|
+
human: $t('Je probeert een bestand up te loaden dat niet door ons is gegenereerd. Probeer het opnieuw.'),
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (Array.isArray(data)) {
|
|
195
|
+
for (const value of data) {
|
|
196
|
+
await this.verifyFilesInStruct(value);
|
|
197
|
+
}
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (data instanceof Map) {
|
|
202
|
+
for (const [key, value] of data.entries()) {
|
|
203
|
+
await this.verifyFilesInStruct(value);
|
|
204
|
+
}
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Loop all keys and search for File objects + replace them with the signed variant
|
|
209
|
+
if (typeof data === 'object' && data !== null) {
|
|
210
|
+
for (const key in data) {
|
|
211
|
+
await this.verifyFilesInStruct(data[key]);
|
|
212
|
+
}
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
static async handleResponse(request: Request, response: Response) {
|
|
218
|
+
const r = await this.fillSignedUrlsForStruct(response.body);
|
|
219
|
+
if (r !== undefined) {
|
|
220
|
+
response.body = r;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
static async handleDecodedRequest(decodedRequest: DecodedRequest<unknown, unknown, unknown>, _endpoint) {
|
|
225
|
+
await this.verifyFilesInStruct(decodedRequest.body);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
@@ -15,14 +15,19 @@ export class PlatformMembershipService {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
// Listen for group changes
|
|
18
|
-
Model.modelEventBus.addListener(this, (event) => {
|
|
19
|
-
if (
|
|
18
|
+
Model.modelEventBus.addListener(this, async (event) => {
|
|
19
|
+
if (event.model instanceof Group) {
|
|
20
|
+
// Check if group has been deleted
|
|
21
|
+
if (event.type === 'deleted' || (event.type === 'updated' && (event.changedFields['deletedAt'] !== undefined || event.changedFields['defaultAgeGroupId'] !== undefined))) {
|
|
22
|
+
PlatformMembershipService.updateMembershipsForGroupId(event.model.id);
|
|
23
|
+
}
|
|
20
24
|
return;
|
|
21
25
|
}
|
|
22
26
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
27
|
+
if (event.model instanceof RegistrationPeriod) {
|
|
28
|
+
if (event.type === 'updated' && event.changedFields['locked'] !== undefined && event.model.locked === true) {
|
|
29
|
+
PlatformMembershipService.setMembershipsLockedForRegistrationPeriodId(event.model.id);
|
|
30
|
+
}
|
|
26
31
|
}
|
|
27
32
|
});
|
|
28
33
|
}
|
|
@@ -80,6 +85,25 @@ export class PlatformMembershipService {
|
|
|
80
85
|
});
|
|
81
86
|
}
|
|
82
87
|
|
|
88
|
+
static setMembershipsLockedForRegistrationPeriodId(periodId: string) {
|
|
89
|
+
if (STAMHOOFD.userMode === 'organization') {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
QueueHandler.schedule('bulk-lock-memberships', async () => {
|
|
94
|
+
console.log('Bulk locking memberships for period id ', periodId);
|
|
95
|
+
|
|
96
|
+
await MemberPlatformMembership.update()
|
|
97
|
+
.set('locked', true)
|
|
98
|
+
.where('periodId', periodId)
|
|
99
|
+
.update();
|
|
100
|
+
|
|
101
|
+
console.log(`Locked memberships for period id ${periodId}`);
|
|
102
|
+
}).catch((e) => {
|
|
103
|
+
console.error('Failed to lock memberships for period id ', periodId, e);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
83
107
|
static updateMembershipsForGroupId(id: string) {
|
|
84
108
|
if (STAMHOOFD.userMode === 'organization') {
|
|
85
109
|
return;
|
|
@@ -186,8 +210,7 @@ export class PlatformMembershipService {
|
|
|
186
210
|
if (!silent) {
|
|
187
211
|
console.log('Removing membership because no longer registered member and not yet invoiced for: ' + me.id + ' - membership ' + membership.id);
|
|
188
212
|
}
|
|
189
|
-
membership.
|
|
190
|
-
await membership.save();
|
|
213
|
+
await membership.doDelete();
|
|
191
214
|
}
|
|
192
215
|
}
|
|
193
216
|
|
|
@@ -234,15 +257,16 @@ export class PlatformMembershipService {
|
|
|
234
257
|
}
|
|
235
258
|
return diff;
|
|
236
259
|
})[0];
|
|
260
|
+
|
|
237
261
|
if (!cheapestMembership) {
|
|
238
262
|
console.error('No membership found');
|
|
239
263
|
continue;
|
|
240
264
|
}
|
|
241
265
|
|
|
242
266
|
// Check if already have the same membership
|
|
267
|
+
let didFind = false;
|
|
243
268
|
for (const m of activeMemberships) {
|
|
244
|
-
|
|
245
|
-
if (m.membershipTypeId === cheapestMembership.membership.id) {
|
|
269
|
+
if (m.membershipTypeId === cheapestMembership.membership.id && m.organizationId === cheapestMembership.registration.organizationId) {
|
|
246
270
|
// Update the price of this active membership (could have changed)
|
|
247
271
|
try {
|
|
248
272
|
await m.calculatePrice(me, cheapestMembership.registration);
|
|
@@ -257,10 +281,10 @@ export class PlatformMembershipService {
|
|
|
257
281
|
didFind = true;
|
|
258
282
|
break;
|
|
259
283
|
}
|
|
284
|
+
}
|
|
260
285
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
}
|
|
286
|
+
if (didFind) {
|
|
287
|
+
continue;
|
|
264
288
|
}
|
|
265
289
|
|
|
266
290
|
const periodConfig = cheapestMembership.membership.periods.get(period.id);
|
|
@@ -273,6 +297,7 @@ export class PlatformMembershipService {
|
|
|
273
297
|
if (!silent) {
|
|
274
298
|
console.log('Creating automatic membership for: ' + me.id + ' - membership type ' + cheapestMembership.membership.id);
|
|
275
299
|
}
|
|
300
|
+
|
|
276
301
|
const membership = new MemberPlatformMembership();
|
|
277
302
|
membership.memberId = me.id;
|
|
278
303
|
membership.membershipTypeId = cheapestMembership.membership.id;
|
|
@@ -303,8 +328,7 @@ export class PlatformMembershipService {
|
|
|
303
328
|
if (!silent) {
|
|
304
329
|
console.log('Removing membership because cheaper membership found for: ' + me.id + ' - membership ' + toDelete.id);
|
|
305
330
|
}
|
|
306
|
-
toDelete.
|
|
307
|
-
await toDelete.save();
|
|
331
|
+
await toDelete.doDelete();
|
|
308
332
|
}
|
|
309
333
|
}
|
|
310
334
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { SQL, SQLFilterDefinitions, baseSQLFilterCompilers, createSQLColumnFilterCompiler, createSQLJoinedRelationFilterCompiler, createSQLRelationFilterCompiler } from '@stamhoofd/sql';
|
|
2
|
+
import { eventFilterCompilers } from './events';
|
|
3
|
+
import { organizationFilterCompilers } from './organizations';
|
|
4
|
+
|
|
5
|
+
export const organizationJoin = SQL.join('organizations').where(SQL.column('organizations', 'id'), SQL.column('event_notifications', 'organizationId'));
|
|
6
|
+
|
|
7
|
+
export const eventNotificationsFilterCompilers: SQLFilterDefinitions = {
|
|
8
|
+
...baseSQLFilterCompilers,
|
|
9
|
+
id: createSQLColumnFilterCompiler('id'),
|
|
10
|
+
typeId: createSQLColumnFilterCompiler('typeId'),
|
|
11
|
+
periodId: createSQLColumnFilterCompiler('periodId'),
|
|
12
|
+
organizationId: createSQLColumnFilterCompiler('organizationId'),
|
|
13
|
+
startDate: createSQLColumnFilterCompiler('startDate'),
|
|
14
|
+
endDate: createSQLColumnFilterCompiler('endDate'),
|
|
15
|
+
submittedAt: createSQLColumnFilterCompiler('submittedAt'),
|
|
16
|
+
createdAt: createSQLColumnFilterCompiler('createdAt'),
|
|
17
|
+
status: createSQLColumnFilterCompiler('status'),
|
|
18
|
+
organization: createSQLJoinedRelationFilterCompiler(
|
|
19
|
+
organizationJoin,
|
|
20
|
+
organizationFilterCompilers,
|
|
21
|
+
),
|
|
22
|
+
events: createSQLRelationFilterCompiler(
|
|
23
|
+
SQL.select()
|
|
24
|
+
.from(
|
|
25
|
+
SQL.table('events'),
|
|
26
|
+
).join(
|
|
27
|
+
SQL.join(
|
|
28
|
+
SQL.table('_event_notifications_events'),
|
|
29
|
+
).where(
|
|
30
|
+
SQL.column('_event_notifications_events', 'eventsId'),
|
|
31
|
+
SQL.column('events', 'id'),
|
|
32
|
+
),
|
|
33
|
+
).where(
|
|
34
|
+
SQL.column('_event_notifications_events', 'event_notificationsId'),
|
|
35
|
+
SQL.column('event_notifications', 'id'),
|
|
36
|
+
),
|
|
37
|
+
eventFilterCompilers,
|
|
38
|
+
),
|
|
39
|
+
};
|
|
@@ -22,7 +22,7 @@ import { SetupStepType } from '@stamhoofd/structures';
|
|
|
22
22
|
export const organizationFilterCompilers: SQLFilterDefinitions = {
|
|
23
23
|
...baseSQLFilterCompilers,
|
|
24
24
|
id: createSQLExpressionFilterCompiler(SQL.column('organizations', 'id')),
|
|
25
|
-
uriPadded: createSQLExpressionFilterCompiler(SQL.lpad(SQL.column('organizations', 'uri'),
|
|
25
|
+
uriPadded: createSQLExpressionFilterCompiler(SQL.lpad(SQL.column('organizations', 'uri'), 10, '0')),
|
|
26
26
|
uri: createSQLExpressionFilterCompiler(SQL.column('organizations', 'uri')),
|
|
27
27
|
name: createSQLExpressionFilterCompiler(
|
|
28
28
|
SQL.column('organizations', 'name'),
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { SQLResultNamespacedRow } from '@simonbackx/simple-database';
|
|
2
|
+
import { SQL, SQLOrderBy, SQLOrderByDirection, SQLSortDefinitions } from '@stamhoofd/sql';
|
|
3
|
+
import { Formatter } from '@stamhoofd/utility';
|
|
4
|
+
import { organizationJoin } from '../sql-filters/event-notifications';
|
|
5
|
+
|
|
6
|
+
export const eventNotificationsSorters: SQLSortDefinitions<SQLResultNamespacedRow> = {
|
|
7
|
+
// WARNING! TEST NEW SORTERS THOROUGHLY!
|
|
8
|
+
// Try to avoid creating sorters on fields that er not 1:1 with the database, that often causes pagination issues if not thought through
|
|
9
|
+
// An example: sorting on 'name' is not a good idea, because it is a concatenation of two fields.
|
|
10
|
+
// 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)
|
|
11
|
+
// 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
|
|
12
|
+
// 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
|
|
13
|
+
// What if you need mapping? simply map the sorters in the frontend: name -> firstname, lastname, age -> birthDay, etc.
|
|
14
|
+
|
|
15
|
+
'id': {
|
|
16
|
+
getValue(a) {
|
|
17
|
+
return a['event_notifications'].id;
|
|
18
|
+
},
|
|
19
|
+
toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
|
|
20
|
+
return new SQLOrderBy({
|
|
21
|
+
column: SQL.column('id'),
|
|
22
|
+
direction,
|
|
23
|
+
});
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
'status': {
|
|
27
|
+
getValue(a) {
|
|
28
|
+
return a['event_notifications'].status;
|
|
29
|
+
},
|
|
30
|
+
toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
|
|
31
|
+
return new SQLOrderBy({
|
|
32
|
+
column: SQL.column('status'),
|
|
33
|
+
direction,
|
|
34
|
+
});
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
'startDate': {
|
|
38
|
+
getValue(a) {
|
|
39
|
+
return Formatter.dateTimeIso(a['event_notifications'].startDate as Date, 'UTC');
|
|
40
|
+
},
|
|
41
|
+
toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
|
|
42
|
+
return new SQLOrderBy({
|
|
43
|
+
column: SQL.column('startDate'),
|
|
44
|
+
direction,
|
|
45
|
+
});
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
'endDate': {
|
|
49
|
+
getValue(a) {
|
|
50
|
+
return Formatter.dateTimeIso(a['event_notifications'].endDate as Date, 'UTC');
|
|
51
|
+
},
|
|
52
|
+
toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
|
|
53
|
+
return new SQLOrderBy({
|
|
54
|
+
column: SQL.column('endDate'),
|
|
55
|
+
direction,
|
|
56
|
+
});
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
'submittedAt': {
|
|
60
|
+
getValue(a) {
|
|
61
|
+
return a['event_notifications'].submittedAt !== null ? Formatter.dateTimeIso(a['event_notifications'].submittedAt as Date, 'UTC') : null;
|
|
62
|
+
},
|
|
63
|
+
toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
|
|
64
|
+
return new SQLOrderBy({
|
|
65
|
+
column: SQL.column('submittedAt'),
|
|
66
|
+
direction,
|
|
67
|
+
});
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
'organization.name': {
|
|
71
|
+
getValue(a) {
|
|
72
|
+
return a.organizations.name;
|
|
73
|
+
},
|
|
74
|
+
toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
|
|
75
|
+
return new SQLOrderBy({
|
|
76
|
+
column: SQL.column('organizations', 'name'),
|
|
77
|
+
direction,
|
|
78
|
+
});
|
|
79
|
+
},
|
|
80
|
+
join: organizationJoin,
|
|
81
|
+
select: [SQL.column('organizations', 'name')],
|
|
82
|
+
},
|
|
83
|
+
'organization.uriPadded': {
|
|
84
|
+
getValue(a) {
|
|
85
|
+
return (a.organizations.uri as string).padStart(10, '0');
|
|
86
|
+
},
|
|
87
|
+
toSQL: (direction: SQLOrderByDirection): SQLOrderBy => {
|
|
88
|
+
return new SQLOrderBy({
|
|
89
|
+
column: SQL.lpad(SQL.column('organizations', 'uri'), 10, '0'),
|
|
90
|
+
direction,
|
|
91
|
+
});
|
|
92
|
+
},
|
|
93
|
+
join: organizationJoin,
|
|
94
|
+
select: [SQL.column('organizations', 'uri')],
|
|
95
|
+
},
|
|
96
|
+
};
|