@stamhoofd/backend 2.86.0 → 2.87.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.86.0",
3
+ "version": "2.87.0",
4
4
  "main": "./dist/index.js",
5
5
  "exports": {
6
6
  ".": {
@@ -23,7 +23,7 @@
23
23
  "lint": "eslint"
24
24
  },
25
25
  "devDependencies": {
26
- "@types/cookie": "^0.5.1",
26
+ "@types/cookie": "^0.6.0",
27
27
  "@types/luxon": "3.4.2",
28
28
  "@types/mailparser": "3.4.4",
29
29
  "@types/mysql": "^2.15.20",
@@ -44,17 +44,17 @@
44
44
  "@simonbackx/simple-encoding": "2.22.0",
45
45
  "@simonbackx/simple-endpoints": "1.20.1",
46
46
  "@simonbackx/simple-logging": "^1.0.1",
47
- "@stamhoofd/backend-i18n": "2.86.0",
48
- "@stamhoofd/backend-middleware": "2.86.0",
49
- "@stamhoofd/email": "2.86.0",
50
- "@stamhoofd/models": "2.86.0",
51
- "@stamhoofd/queues": "2.86.0",
52
- "@stamhoofd/sql": "2.86.0",
53
- "@stamhoofd/structures": "2.86.0",
54
- "@stamhoofd/utility": "2.86.0",
47
+ "@stamhoofd/backend-i18n": "2.87.0",
48
+ "@stamhoofd/backend-middleware": "2.87.0",
49
+ "@stamhoofd/email": "2.87.0",
50
+ "@stamhoofd/models": "2.87.0",
51
+ "@stamhoofd/queues": "2.87.0",
52
+ "@stamhoofd/sql": "2.87.0",
53
+ "@stamhoofd/structures": "2.87.0",
54
+ "@stamhoofd/utility": "2.87.0",
55
55
  "archiver": "^7.0.1",
56
56
  "axios": "^1.8.2",
57
- "cookie": "^0.5.0",
57
+ "cookie": "^0.7.0",
58
58
  "formidable": "3.5.4",
59
59
  "handlebars": "^4.7.7",
60
60
  "jsonwebtoken": "9.0.0",
@@ -69,5 +69,5 @@
69
69
  "publishConfig": {
70
70
  "access": "public"
71
71
  },
72
- "gitHead": "e7db435d283a81bee4f7d26c2ccc61ee1df0524c"
72
+ "gitHead": "372713e1e4b626d6b4ce4f3d148f14948528312e"
73
73
  }
@@ -0,0 +1,37 @@
1
+ import { registerCron } from '@stamhoofd/crons';
2
+ import { Email } from '@stamhoofd/models';
3
+ import { EmailStatus } from '@stamhoofd/structures';
4
+
5
+ let lastRunDate: number | null = null;
6
+
7
+ registerCron('deleteOldEmailDrafts', deleteOldEmailDrafts);
8
+
9
+ /**
10
+ * Run every night at 5 AM.
11
+ */
12
+ export async function deleteOldEmailDrafts() {
13
+ const now = new Date();
14
+
15
+ if (now.getDate() === lastRunDate) {
16
+ return;
17
+ }
18
+
19
+ const hour = now.getHours();
20
+
21
+ // between 5 and 6 AM
22
+ if (hour !== 5 && STAMHOOFD.environment !== 'development') {
23
+ return;
24
+ }
25
+
26
+ // Clear old email drafts older than 7 days
27
+ const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
28
+ const result = await Email.delete()
29
+ .where('status', EmailStatus.Draft)
30
+ .where('createdAt', '<', sevenDaysAgo)
31
+ .where('updatedAt', '<', sevenDaysAgo)
32
+ .where('sentAt', null);
33
+
34
+ console.log(`Deleted ${result.affectedRows} old email drafts.`);
35
+
36
+ lastRunDate = now.getDate();
37
+ }
@@ -1,8 +1,12 @@
1
1
  import { registerCron } from '@stamhoofd/crons';
2
2
  import { FlagMomentCleanup } from '../helpers/FlagMomentCleanup';
3
3
 
4
- let lastCleanupYear: number = -1;
5
- let lastCleanupMonth: number = -1;
4
+ // Only delete responsibilities when the server is running during a month change.
5
+ // Chances are almost zero that we reboot during a month change
6
+ // Running on every reboot also would have unintended consequences
7
+ const now = new Date();
8
+ let lastCleanupYear: number = now.getFullYear();
9
+ let lastCleanupMonth: number = now.getMonth();
6
10
 
7
11
  registerCron('endFunctionsOfUsersWithoutRegistration', endFunctionsOfUsersWithoutRegistration);
8
12
 
@@ -3,3 +3,4 @@ import './clearExcelCache.js';
3
3
  import './endFunctionsOfUsersWithoutRegistration.js';
4
4
  import './update-cached-balances.js';
5
5
  import './balance-emails.js';
6
+ import './delete-old-email-drafts.js';
@@ -135,40 +135,9 @@ export class UploadFile extends Endpoint<Params, Query, Body, ResponseBody> {
135
135
 
136
136
  // Also include the source, in private mode
137
137
  const fileId = uuidv4();
138
- let uploadExt = '';
139
-
140
- switch (file.mimetype?.toLocaleLowerCase()) {
141
- case 'image/jpeg':
142
- case 'image/jpg':
143
- uploadExt = 'jpg';
144
- break;
145
- case 'image/png':
146
- uploadExt = 'png';
147
- break;
148
- case 'image/gif':
149
- uploadExt = 'gif';
150
- break;
151
- case 'image/webp':
152
- uploadExt = 'webp';
153
- break;
154
- case 'image/svg+xml':
155
- uploadExt = 'svg';
156
- break;
157
- case 'application/pdf':
158
- uploadExt = 'pdf';
159
- break;
160
- case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':
161
- case 'application/vnd.ms-excel':
162
- uploadExt = 'xlsx';
163
- break;
164
-
165
- case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
166
- case 'application/msword':
167
- uploadExt = 'docx';
168
- break;
169
- }
138
+ const uploadExt = File.contentTypeToExtension(file.mimetype ?? '') ?? '';
170
139
 
171
- const filenameWithoutExt = file.originalFilename?.split('.').slice(0, -1).join('.') ?? fileId;
140
+ const filenameWithoutExt = file.originalFilename ? File.removeExtension(file.originalFilename) : fileId;
172
141
  const key = prefix + fileId + '/' + (Formatter.slug(filenameWithoutExt) + (uploadExt ? ('.' + uploadExt) : ''));
173
142
 
174
143
  const fileStruct = new File({
@@ -178,6 +147,7 @@ export class UploadFile extends Endpoint<Params, Query, Body, ResponseBody> {
178
147
  size: fileContent.length,
179
148
  name: file.originalFilename,
180
149
  isPrivate: request.query.isPrivate,
150
+ contentType: file.mimetype ?? null,
181
151
  });
182
152
 
183
153
  // Generate an upload signature for this file if it is private
@@ -196,7 +166,7 @@ export class UploadFile extends Endpoint<Params, Query, Body, ResponseBody> {
196
166
  Bucket: STAMHOOFD.SPACES_BUCKET,
197
167
  Key: key,
198
168
  Body: fileContent,
199
- ContentType: file.mimetype ?? 'application/pdf',
169
+ ContentType: file.mimetype ?? 'application/octet-stream',
200
170
  ACL: request.query.isPrivate ? 'private' : 'public-read',
201
171
  });
202
172
  await Image.getS3Client().send(cmd);
@@ -286,6 +286,7 @@ export class GetMembersEndpoint extends Endpoint<Params, Query, Body, ResponseBo
286
286
 
287
287
  for (const member of members) {
288
288
  if (!await Context.auth.canAccessMember(member, permissionLevel)) {
289
+ console.error('Unexpected member returned', member.id, requestQuery, query.getSQL());
289
290
  throw Context.auth.error();
290
291
  }
291
292
  }
@@ -1,8 +1,8 @@
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 { EmailVerificationCode, Organization, User } from '@stamhoofd/models';
5
- import { CreateOrganization, PermissionLevel, Permissions, SignupResponse, UserPermissions } from '@stamhoofd/structures';
4
+ import { EmailVerificationCode, Organization, RegistrationPeriod, User } from '@stamhoofd/models';
5
+ import { CreateOrganization, PermissionLevel, Permissions, RegistrationPeriodSettings, SignupResponse, UserPermissions } from '@stamhoofd/structures';
6
6
  import { Formatter } from '@stamhoofd/utility';
7
7
 
8
8
  type Params = Record<string, never>;
@@ -85,6 +85,16 @@ export class CreateOrganizationEndpoint extends Endpoint<Params, Query, Body, Re
85
85
  organization.address = request.body.organization.address;
86
86
  organization.privateMeta.acquisitionTypes = request.body.organization.privateMeta?.acquisitionTypes ?? [];
87
87
 
88
+ const period = new RegistrationPeriod();
89
+
90
+ // WIP
91
+ period.settings = RegistrationPeriodSettings.create({});
92
+ period.startDate = new Date();
93
+ period.endDate = new Date(Date.now() + 1000 * 60 * 60 * 24 * 31); // 1 month
94
+
95
+ await period.save();
96
+ organization.periodId = period.id;
97
+
88
98
  try {
89
99
  await organization.save();
90
100
  }
@@ -96,6 +106,8 @@ export class CreateOrganizationEndpoint extends Endpoint<Params, Query, Body, Re
96
106
  statusCode: 500,
97
107
  });
98
108
  }
109
+ period.organizationId = organization.id;
110
+ await period.save();
99
111
 
100
112
  const user = await User.register(
101
113
  organization,
@@ -294,8 +294,8 @@ export class PatchOrganizationRegistrationPeriodsEndpoint extends Endpoint<Param
294
294
  });
295
295
  }
296
296
 
297
- const maximumStart = 1000 * 60 * 60 * 24 * 31 * 2; // 2 months in advance
298
- if (STAMHOOFD.environment !== 'development' && period.startDate > new Date(Date.now() + maximumStart)) {
297
+ const maximumStart = 1000 * 60 * 60 * 24 * 31 * 8; // 8 months in advance
298
+ if (period.startDate > new Date(Date.now() + maximumStart)) {
299
299
  throw new SimpleError({
300
300
  code: 'invalid_field',
301
301
  message: 'Period start date is too far in the future',
@@ -181,8 +181,10 @@ export class AdminPermissionChecker {
181
181
  const organization = await this.getOrganization(group.organizationId);
182
182
 
183
183
  if (group.periodId !== organization.periodId) {
184
- if (!await this.hasFullAccess(group.organizationId)) {
185
- return false;
184
+ if (STAMHOOFD.userMode === 'organization' || group.periodId !== this.platform.period.id) {
185
+ if (!await this.hasFullAccess(group.organizationId)) {
186
+ return false;
187
+ }
186
188
  }
187
189
  }
188
190
 
@@ -940,7 +942,7 @@ export class AdminPermissionChecker {
940
942
  /**
941
943
  * Return a list of RecordSettings the current user can view or edit
942
944
  */
943
- async hasFinancialMemberAccess(member: MemberWithRegistrations, level: PermissionLevel = PermissionLevel.Read): Promise<boolean> {
945
+ async hasFinancialMemberAccess(member: MemberWithRegistrations, level: PermissionLevel = PermissionLevel.Read, organizationId?: string): Promise<boolean> {
944
946
  const isUserManager = this.isUserManager(member);
945
947
 
946
948
  if (isUserManager && level === PermissionLevel.Read) {
@@ -959,11 +961,19 @@ export class AdminPermissionChecker {
959
961
  organizations.push(await this.getOrganization(member.organizationId));
960
962
  }
961
963
  }
962
-
963
- for (const registration of member.registrations) {
964
- if (this.checkScope(registration.organizationId)) {
965
- if (!organizations.find(o => o.id === registration.organizationId)) {
966
- organizations.push(await this.getOrganization(registration.organizationId));
964
+ else {
965
+ if (organizationId) {
966
+ if (this.checkScope(organizationId)) {
967
+ organizations.push(await this.getOrganization(organizationId));
968
+ }
969
+ }
970
+ else {
971
+ for (const registration of member.registrations) {
972
+ if (this.checkScope(registration.organizationId)) {
973
+ if (!organizations.find(o => o.id === registration.organizationId)) {
974
+ organizations.push(await this.getOrganization(registration.organizationId));
975
+ }
976
+ }
967
977
  }
968
978
  }
969
979
  }
@@ -400,6 +400,10 @@ export class AuthenticatedStructures {
400
400
  await BalanceItemService.flushCaches(Context.organization.id);
401
401
  }
402
402
  const balances = await CachedBalance.getForObjects(registrationIds, null);
403
+ const memberIds = members.map(m => m.id);
404
+ const allMemberBalances = Context.organization
405
+ ? (await CachedBalance.getForObjects(memberIds, Context.organization.id))
406
+ : [];
403
407
 
404
408
  if (includeUser) {
405
409
  for (const organizationId of includeUser.permissions?.organizationPermissions.keys() ?? []) {
@@ -451,15 +455,27 @@ export class AuthenticatedStructures {
451
455
  }
452
456
  }
453
457
  member.registrations = member.registrations.filter(r => (Context.auth.organization && Context.auth.organization.active && r.organizationId === Context.auth.organization.id) || (organizations.get(r.organizationId)?.active ?? false));
454
- const balancesPermission = await Context.auth.hasFinancialMemberAccess(member, PermissionLevel.Read);
458
+ const balancesPermission = await Context.auth.hasFinancialMemberAccess(member, PermissionLevel.Read, Context.organization?.id);
459
+
460
+ let memberBalances: GenericBalance[] = [];
461
+
462
+ if (balancesPermission && Context.organization) {
463
+ // Only return balances if in an organization scope and you have permission for the finances of that specific organization AND member
464
+ memberBalances = allMemberBalances
465
+ .filter(b => member.id === b.objectId && b.objectType === ReceivableBalanceType.member)
466
+ .map((b) => {
467
+ return GenericBalance.create(b);
468
+ });
469
+ }
455
470
 
456
471
  const blob = MemberWithRegistrationsBlob.create({
457
472
  ...member,
473
+ balances: memberBalances,
458
474
  registrations: member.registrations.map((r) => {
459
475
  const base = r.getStructure();
460
476
 
461
477
  base.balances = balancesPermission
462
- ? (balances.filter(b => r.id === b.objectId).map((b) => {
478
+ ? (balances.filter(b => r.id === b.objectId && b.objectType === ReceivableBalanceType.registration).map((b) => {
463
479
  return GenericBalance.create(b);
464
480
  }))
465
481
  : [];
@@ -579,7 +595,6 @@ export class AuthenticatedStructures {
579
595
 
580
596
  return RegistrationWithMemberBlob.create({
581
597
  ...registration,
582
- balances: memberBlob.registrations.find(r => r.id === registration.id)?.balances ?? [],
583
598
  member: memberBlob,
584
599
  });
585
600
  });
@@ -874,15 +889,15 @@ export class AuthenticatedStructures {
874
889
 
875
890
  ...((member.details.defaultAge <= 18 || member.details.getMemberEmails().length === 0)
876
891
  ? member.details.parents.filter(p => p.getEmails().length > 0).map(p => ReceivableBalanceObjectContact.create({
877
- firstName: p.firstName ?? '',
878
- lastName: p.lastName ?? '',
879
- emails: p.getEmails(),
880
- meta: {
881
- type: 'parent',
882
- responsibilityIds: [],
883
- url,
884
- },
885
- }))
892
+ firstName: p.firstName ?? '',
893
+ lastName: p.lastName ?? '',
894
+ emails: p.getEmails(),
895
+ meta: {
896
+ type: 'parent',
897
+ responsibilityIds: [],
898
+ url,
899
+ },
900
+ }))
886
901
  : []),
887
902
  ],
888
903
  });
@@ -917,15 +932,15 @@ export class AuthenticatedStructures {
917
932
 
918
933
  ...((member.details.defaultAge <= 18 || member.details.getMemberEmails().length === 0)
919
934
  ? member.details.parents.filter(p => p.getEmails().length > 0).map(p => ReceivableBalanceObjectContact.create({
920
- firstName: p.firstName ?? '',
921
- lastName: p.lastName ?? '',
922
- emails: p.getEmails(),
923
- meta: {
924
- type: 'parent',
925
- responsibilityIds: [],
926
- url,
927
- },
928
- }))
935
+ firstName: p.firstName ?? '',
936
+ lastName: p.lastName ?? '',
937
+ emails: p.getEmails(),
938
+ meta: {
939
+ type: 'parent',
940
+ responsibilityIds: [],
941
+ url,
942
+ },
943
+ }))
929
944
  : []),
930
945
  ],
931
946
  });
@@ -1,6 +1,7 @@
1
- import { Group, MemberResponsibilityRecord, Platform, Registration } from '@stamhoofd/models';
2
- import { SQL, SQLWhereExists, SQLWhereSign } from '@stamhoofd/sql';
1
+ import { Group, Member, MemberResponsibilityRecord, Platform, Registration } from '@stamhoofd/models';
2
+ import { SQL, SQLWhereExists } from '@stamhoofd/sql';
3
3
  import { GroupType } from '@stamhoofd/structures';
4
+ import { MemberUserSyncer } from './MemberUserSyncer';
4
5
 
5
6
  export class FlagMomentCleanup {
6
7
  /**
@@ -16,6 +17,11 @@ export class FlagMomentCleanup {
16
17
  responsibility.endDate = now;
17
18
  await responsibility.save();
18
19
  console.log(`Ended responsibility with id ${responsibility.id}`);
20
+
21
+ const member = await Member.getByID(responsibility.memberId);
22
+ if (member) {
23
+ await MemberUserSyncer.onChangeMember(member);
24
+ }
19
25
  }));
20
26
  }
21
27
 
@@ -50,9 +56,15 @@ export class FlagMomentCleanup {
50
56
  ).where(
51
57
  SQL.column(Registration.table, 'deactivatedAt'),
52
58
  null,
59
+ ).whereNot(
60
+ SQL.column(Registration.table, 'registeredAt'),
61
+ null,
53
62
  ).where(
54
63
  SQL.column(Group.table, 'type'),
55
64
  GroupType.Membership,
65
+ ).where(
66
+ SQL.column(Group.table, 'deletedAt'),
67
+ null,
56
68
  ),
57
69
  ),
58
70
  )
@@ -0,0 +1,106 @@
1
+ import { PutObjectCommand } from '@aws-sdk/client-s3';
2
+ import { Migration } from '@simonbackx/simple-database';
3
+ import { SimpleError } from '@simonbackx/simple-errors';
4
+ import { logger } from '@simonbackx/simple-logging';
5
+ import { Email, Image } from '@stamhoofd/models';
6
+ import { File } from '@stamhoofd/structures';
7
+ import { Formatter } from '@stamhoofd/utility';
8
+ import { v4 as uuidv4 } from 'uuid';
9
+
10
+ /**
11
+ * Replace all base64 email attachments with file attachments.
12
+ */
13
+ export default new Migration(async () => {
14
+ if (STAMHOOFD.environment == 'test') {
15
+ console.log('skipped in tests');
16
+ return;
17
+ }
18
+
19
+ process.stdout.write('\n');
20
+ let c = 0;
21
+
22
+ await logger.setContext({ tags: ['seed'] }, async () => {
23
+ for await (const email of Email.select().limit(10).all()) {
24
+ let save = false;
25
+ for (const attachment of email.attachments) {
26
+ if (attachment.content) {
27
+ try {
28
+ console.log('Uploading base64 attachment for email ' + email.id);
29
+ save = true;
30
+
31
+ let prefix = (STAMHOOFD.SPACES_PREFIX ?? '');
32
+ if (prefix.length > 0) {
33
+ prefix += '/';
34
+ }
35
+
36
+ prefix += (STAMHOOFD.environment ?? 'development') === 'development' ? ('development/') : ('');
37
+ // Prepend user id to the file path
38
+ // Private files
39
+ if (email.userId) {
40
+ prefix += 'users/' + email.userId + '/';
41
+ }
42
+ else {
43
+ // Public files
44
+ prefix += 'p/';
45
+ }
46
+
47
+ const fileContent = Buffer.from(attachment.content, 'base64');
48
+
49
+ const fileId = uuidv4();
50
+ const uploadExt = File.contentTypeToExtension(attachment.contentType ?? '') ?? '';
51
+ const filenameWithoutExt = File.removeExtension(attachment.filename);
52
+ const key = prefix + fileId + '/' + (Formatter.slug(filenameWithoutExt) + (uploadExt ? ('.' + uploadExt) : ''));
53
+
54
+ const fileStruct = new File({
55
+ id: fileId,
56
+ server: 'https://' + STAMHOOFD.SPACES_BUCKET + '.' + STAMHOOFD.SPACES_ENDPOINT,
57
+ path: key,
58
+ size: fileContent.length,
59
+ name: attachment.filename,
60
+ isPrivate: true,
61
+ contentType: attachment.contentType ?? null,
62
+ });
63
+
64
+ // Generate an upload signature for this file if it is private
65
+ if (!await fileStruct.sign()) {
66
+ throw new SimpleError({
67
+ code: 'failed_to_sign',
68
+ message: 'Failed to sign file',
69
+ statusCode: 500,
70
+ });
71
+ }
72
+
73
+ const cmd = new PutObjectCommand({
74
+ Bucket: STAMHOOFD.SPACES_BUCKET,
75
+ Key: key,
76
+ Body: fileContent,
77
+ ContentType: attachment.contentType ?? 'application/octet-stream',
78
+ ACL: 'private',
79
+ });
80
+ await Image.getS3Client().send(cmd);
81
+
82
+ console.log('Successfully uploaded base64 attachment for email ' + email.id + ' as file ' + fileStruct.id);
83
+
84
+ attachment.file = fileStruct;
85
+ attachment.content = null; // Clear the base64 content
86
+ }
87
+ catch (e) {
88
+ console.error('Failed to upload base64 attachment for email ' + email.id, e);
89
+ continue; // Skip this email if it fails
90
+ }
91
+ }
92
+ }
93
+
94
+ if (save) {
95
+ await email.save();
96
+ c++;
97
+ process.stdout.write('.');
98
+ }
99
+ }
100
+ });
101
+
102
+ console.log('\nUpdated attachments for ' + c + ' emails');
103
+
104
+ // Do something here
105
+ return Promise.resolve();
106
+ });
@@ -152,7 +152,7 @@ export class EventNotificationService {
152
152
  }),
153
153
  Replacement.create({
154
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))),
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
156
  }),
157
157
  Replacement.create({
158
158
  token: 'dateRange',
@@ -170,7 +170,11 @@ export const memberFilterCompilers: SQLFilterDefinitions = {
170
170
  SQL.column('groups', 'deletedAt'),
171
171
  null,
172
172
  ),
173
- baseRegistrationFilterCompilers,
173
+ {
174
+ ...baseRegistrationFilterCompilers,
175
+ // Override the registration periodId - can be outdated - and always use the group periodId
176
+ periodId: createSQLColumnFilterCompiler(SQL.column('groups', 'periodId')),
177
+ },
174
178
  ),
175
179
  'responsibilities': createSQLRelationFilterCompiler(
176
180
  SQL.select()