@stamhoofd/backend 2.93.0 → 2.95.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.93.0",
3
+ "version": "2.95.0",
4
4
  "main": "./dist/index.js",
5
5
  "exports": {
6
6
  ".": {
@@ -45,14 +45,14 @@
45
45
  "@simonbackx/simple-encoding": "2.22.0",
46
46
  "@simonbackx/simple-endpoints": "1.20.1",
47
47
  "@simonbackx/simple-logging": "^1.0.1",
48
- "@stamhoofd/backend-i18n": "2.93.0",
49
- "@stamhoofd/backend-middleware": "2.93.0",
50
- "@stamhoofd/email": "2.93.0",
51
- "@stamhoofd/models": "2.93.0",
52
- "@stamhoofd/queues": "2.93.0",
53
- "@stamhoofd/sql": "2.93.0",
54
- "@stamhoofd/structures": "2.93.0",
55
- "@stamhoofd/utility": "2.93.0",
48
+ "@stamhoofd/backend-i18n": "2.95.0",
49
+ "@stamhoofd/backend-middleware": "2.95.0",
50
+ "@stamhoofd/email": "2.95.0",
51
+ "@stamhoofd/models": "2.95.0",
52
+ "@stamhoofd/queues": "2.95.0",
53
+ "@stamhoofd/sql": "2.95.0",
54
+ "@stamhoofd/structures": "2.95.0",
55
+ "@stamhoofd/utility": "2.95.0",
56
56
  "archiver": "^7.0.1",
57
57
  "axios": "^1.8.2",
58
58
  "cookie": "^0.7.0",
@@ -70,5 +70,5 @@
70
70
  "publishConfig": {
71
71
  "access": "public"
72
72
  },
73
- "gitHead": "05b8228a7fc3871aa767609a95a2c1511132e548"
73
+ "gitHead": "baed3abb7d0814204ae52004bfec7287e397d28e"
74
74
  }
@@ -130,6 +130,24 @@ export class CreateEmailEndpoint extends Endpoint<Params, Query, Body, ResponseB
130
130
  await model.queueForSending();
131
131
  }
132
132
 
133
+ // Delete open drafts with the same content, from the same user
134
+ const duplicates = await Email.select()
135
+ .where('userId', user.id)
136
+ .where('organizationId', model.organizationId)
137
+ .where('status', EmailStatus.Draft)
138
+ .where('subject', model.subject)
139
+ .where('html', model.html)
140
+ .where('text', model.text)
141
+ .where('deletedAt', null)
142
+ .whereNot('id', model.id)
143
+ .limit(100)
144
+ .fetch();
145
+
146
+ for (const duplicate of duplicates) {
147
+ duplicate.deletedAt = new Date();
148
+ await duplicate.save();
149
+ }
150
+
133
151
  return new Response(await model.getPreviewStructure());
134
152
  }
135
153
  }
@@ -1,5 +1,5 @@
1
1
  import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
2
- import { assertSort, CountFilteredRequest, EmailPreview, EmailStatus, getSortFilter, LimitedFilteredRequest, PaginatedResponse, StamhoofdFilter } from '@stamhoofd/structures';
2
+ import { assertSort, CountFilteredRequest, EmailPreview, EmailStatus, getSortFilter, LimitedFilteredRequest, mergeFilters, PaginatedResponse, PermissionLevel, StamhoofdFilter } from '@stamhoofd/structures';
3
3
 
4
4
  import { Decoder } from '@simonbackx/simple-encoding';
5
5
  import { SimpleError } from '@simonbackx/simple-errors';
@@ -40,12 +40,15 @@ export class GetAdminEmailsEndpoint extends Endpoint<Params, Query, Body, Respon
40
40
  throw new Error('Not authenticated');
41
41
  }
42
42
 
43
- let scopeFilter: StamhoofdFilter | undefined = undefined;
43
+ let scopeFilter: StamhoofdFilter = null;
44
44
 
45
45
  const canReadAllEmails = await Context.auth.canReadAllEmails(organization ?? null);
46
- scopeFilter = {
47
- organizationId: organization?.id ?? null,
48
- };
46
+
47
+ if (organization || Context.auth.getPlatformAccessibleOrganizationTags(PermissionLevel.Full) !== 'all') {
48
+ scopeFilter = {
49
+ organizationId: organization?.id ?? null,
50
+ };
51
+ }
49
52
 
50
53
  if (!canReadAllEmails) {
51
54
  const senders = organization ? organization.privateMeta.emails : (await Platform.getShared()).privateConfig.emails;
@@ -59,52 +62,39 @@ export class GetAdminEmailsEndpoint extends Endpoint<Params, Query, Body, Respon
59
62
  throw Context.auth.error();
60
63
  }
61
64
 
62
- scopeFilter = {
63
- $and: [
65
+ scopeFilter = mergeFilters([scopeFilter, {
66
+ $or: [
64
67
  {
65
- organizationId: organization?.id ?? null,
68
+ senderId: {
69
+ $in: ids,
70
+ },
71
+ status: {
72
+ $neq: EmailStatus.Draft,
73
+ },
66
74
  },
67
75
  {
68
- $or: [
69
- {
70
- senderId: {
71
- $in: ids,
72
- },
73
- status: {
74
- $neq: EmailStatus.Draft,
75
- },
76
- },
77
- {
78
- userId: user.id,
79
- },
80
- ],
76
+ userId: user.id,
81
77
  },
82
78
  ],
83
- };
79
+ }]);
84
80
  }
85
81
  else {
86
- scopeFilter = {
87
- $and: [
82
+ scopeFilter = mergeFilters([scopeFilter, {
83
+ $or: [
88
84
  {
89
- organizationId: organization?.id ?? null,
85
+ status: {
86
+ $neq: EmailStatus.Draft,
87
+ },
90
88
  },
91
89
  {
92
- $or: [
93
- {
94
- status: {
95
- $neq: EmailStatus.Draft,
96
- },
97
- },
98
- {
99
- userId: user.id,
100
- },
101
- ],
90
+ userId: user.id,
102
91
  },
103
92
  ],
104
- };
93
+ }]);
105
94
  }
106
95
 
107
- const query = Email.select();
96
+ const query = Email.select()
97
+ .where('deletedAt', null);
108
98
 
109
99
  if (scopeFilter) {
110
100
  query.where(await compileToSQLFilter(scopeFilter, filterCompilers));
@@ -30,14 +30,14 @@ export class GetEmailEndpoint extends Endpoint<Params, Query, Body, ResponseBody
30
30
 
31
31
  async handle(request: DecodedRequest<Params, Query, Body>) {
32
32
  const organization = await Context.setOptionalOrganizationScope();
33
- const { user } = await Context.authenticate();
33
+ await Context.authenticate();
34
34
 
35
35
  if (!await Context.auth.canReadEmails(organization)) {
36
36
  throw Context.auth.error();
37
37
  }
38
38
 
39
39
  const model = await Email.getByID(request.params.id);
40
- if (!model || model.userId !== user.id || (model.organizationId !== (organization?.id ?? null))) {
40
+ if (!model || (model.organizationId !== (organization?.id ?? null))) {
41
41
  throw new SimpleError({
42
42
  code: 'not_found',
43
43
  human: 'Email not found',
@@ -0,0 +1,163 @@
1
+ import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
2
+ import { assertSort, CountFilteredRequest, EmailWithRecipients, EmailStatus, getSortFilter, LimitedFilteredRequest, mergeFilters, PaginatedResponse, StamhoofdFilter } from '@stamhoofd/structures';
3
+
4
+ import { Decoder } from '@simonbackx/simple-encoding';
5
+ import { SimpleError } from '@simonbackx/simple-errors';
6
+ import { Email, Member } from '@stamhoofd/models';
7
+ import { applySQLSorter, compileToSQLFilter, SQLFilterDefinitions, SQLSortDefinitions } from '@stamhoofd/sql';
8
+ import { Context } from '../../../helpers/Context';
9
+ import { emailFilterCompilers, userEmailFilterCompilers } from '../../../sql-filters/emails';
10
+ import { emailSorters } from '../../../sql-sorters/emails';
11
+
12
+ type Params = Record<string, never>;
13
+ type Query = LimitedFilteredRequest;
14
+ type Body = undefined;
15
+ type ResponseBody = PaginatedResponse<EmailWithRecipients[], LimitedFilteredRequest>;
16
+
17
+ const filterCompilers: SQLFilterDefinitions = emailFilterCompilers;
18
+ const sorters: SQLSortDefinitions<Email> = emailSorters;
19
+
20
+ export class GetUserEmailsEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
21
+ queryDecoder = LimitedFilteredRequest as Decoder<LimitedFilteredRequest>;
22
+
23
+ protected doesMatch(request: Request): [true, Params] | [false] {
24
+ if (request.method !== 'GET') {
25
+ return [false];
26
+ }
27
+
28
+ const params = Endpoint.parseParameters(request.url, '/user/email', {});
29
+
30
+ if (params) {
31
+ return [true, params as Params];
32
+ }
33
+ return [false];
34
+ }
35
+
36
+ static async buildQuery(memberIds: string[], q: CountFilteredRequest | LimitedFilteredRequest) {
37
+ const organization = Context.organization;
38
+ const user = Context.user;
39
+ if (!user) {
40
+ throw new Error('Not authenticated');
41
+ }
42
+
43
+ let scopeFilter: StamhoofdFilter | null = null;
44
+
45
+ if (organization) {
46
+ scopeFilter = mergeFilters([scopeFilter, {
47
+ organizationId: organization.id,
48
+ }]);
49
+ }
50
+
51
+ scopeFilter = mergeFilters([scopeFilter, {
52
+ recipients: {
53
+ $elemMatch: {
54
+ memberId: {
55
+ $in: memberIds,
56
+ },
57
+ },
58
+ },
59
+ }]);
60
+
61
+ const query = Email.select()
62
+ .where('deletedAt', null)
63
+ .where('status', EmailStatus.Sent);
64
+
65
+ if (scopeFilter) {
66
+ query.where(await compileToSQLFilter(scopeFilter, filterCompilers));
67
+ }
68
+
69
+ if (q.filter) {
70
+ // This one is more strict
71
+ query.where(await compileToSQLFilter(q.filter, userEmailFilterCompilers));
72
+ }
73
+
74
+ if (q.search) {
75
+ let searchFilter: StamhoofdFilter | null = null;
76
+
77
+ searchFilter = {
78
+ subject: {
79
+ $contains: q.search,
80
+ },
81
+ };
82
+
83
+ if (searchFilter) {
84
+ query.where(await compileToSQLFilter(searchFilter, filterCompilers));
85
+ }
86
+ }
87
+
88
+ if (q instanceof LimitedFilteredRequest) {
89
+ if (q.pageFilter) {
90
+ // More strict as well, since this comes from the frontend
91
+ query.where(await compileToSQLFilter(q.pageFilter, userEmailFilterCompilers));
92
+ }
93
+
94
+ q.sort = assertSort(q.sort, [{ key: 'id' }]);
95
+ applySQLSorter(query, q.sort, sorters);
96
+ query.limit(q.limit);
97
+ }
98
+
99
+ return query;
100
+ }
101
+
102
+ static async buildData(requestQuery: LimitedFilteredRequest) {
103
+ const user = Context.user;
104
+ if (!user) {
105
+ throw new Error('Not authenticated');
106
+ }
107
+ const memberIds = await Member.getMemberIdsForUser(user);
108
+ const query = await GetUserEmailsEndpoint.buildQuery(memberIds, requestQuery);
109
+ const emails = await query.fetch();
110
+
111
+ let next: LimitedFilteredRequest | undefined;
112
+
113
+ if (emails.length >= requestQuery.limit) {
114
+ const lastObject = emails[emails.length - 1];
115
+ const nextFilter = getSortFilter(lastObject, sorters, requestQuery.sort);
116
+
117
+ next = new LimitedFilteredRequest({
118
+ filter: requestQuery.filter,
119
+ pageFilter: nextFilter,
120
+ sort: requestQuery.sort,
121
+ limit: requestQuery.limit,
122
+ search: requestQuery.search,
123
+ });
124
+
125
+ if (JSON.stringify(nextFilter) === JSON.stringify(requestQuery.pageFilter)) {
126
+ console.error('Found infinite loading loop for', requestQuery);
127
+ next = undefined;
128
+ }
129
+ }
130
+
131
+ return new PaginatedResponse<EmailWithRecipients[], LimitedFilteredRequest>({
132
+ results: await Promise.all(emails.map(email => email.getStructureForUser(user, memberIds))),
133
+ next,
134
+ });
135
+ }
136
+
137
+ async handle(request: DecodedRequest<Params, Query, Body>) {
138
+ await Context.setOptionalOrganizationScope();
139
+ await Context.authenticate();
140
+
141
+ const maxLimit = Context.auth.hasSomePlatformAccess() ? 1000 : 100;
142
+
143
+ if (request.query.limit > maxLimit) {
144
+ throw new SimpleError({
145
+ code: 'invalid_field',
146
+ field: 'limit',
147
+ message: 'Limit can not be more than ' + maxLimit,
148
+ });
149
+ }
150
+
151
+ if (request.query.limit < 1) {
152
+ throw new SimpleError({
153
+ code: 'invalid_field',
154
+ field: 'limit',
155
+ message: 'Limit can not be less than 1',
156
+ });
157
+ }
158
+
159
+ return new Response(
160
+ await GetUserEmailsEndpoint.buildData(request.query),
161
+ );
162
+ }
163
+ }
@@ -50,15 +50,6 @@ export class PatchEmailEndpoint extends Endpoint<Params, Query, Body, ResponseBo
50
50
  throw Context.auth.error();
51
51
  }
52
52
 
53
- if (model.status !== EmailStatus.Draft) {
54
- throw new SimpleError({
55
- code: 'not_draft',
56
- human: 'Email is not a draft',
57
- message: $t(`298b5a46-2899-4aa1-89df-9b634c20806b`),
58
- statusCode: 400,
59
- });
60
- }
61
-
62
53
  let rebuild = false;
63
54
 
64
55
  if (request.body.subject !== undefined) {
@@ -78,6 +69,11 @@ export class PatchEmailEndpoint extends Endpoint<Params, Query, Body, ResponseBo
78
69
  model.senderId = sender.id;
79
70
  model.fromAddress = sender.email;
80
71
  model.fromName = sender.name;
72
+
73
+ // Check if we still have write access to the email
74
+ if (!await Context.auth.canAccessEmail(model, PermissionLevel.Write)) {
75
+ throw Context.auth.error();
76
+ }
81
77
  }
82
78
  else {
83
79
  throw new SimpleError({
@@ -138,6 +134,13 @@ export class PatchEmailEndpoint extends Endpoint<Params, Query, Body, ResponseBo
138
134
  model.validateAttachments();
139
135
  }
140
136
 
137
+ if (request.body.deletedAt !== undefined) {
138
+ if (!await Context.auth.canAccessEmail(model, PermissionLevel.Full)) {
139
+ throw Context.auth.error();
140
+ }
141
+ model.deletedAt = request.body.deletedAt;
142
+ }
143
+
141
144
  await model.save();
142
145
 
143
146
  if (rebuild) {
@@ -3,7 +3,7 @@ import { assertSort, CountFilteredRequest, EmailRecipient as EmailRecipientStruc
3
3
 
4
4
  import { Decoder } from '@simonbackx/simple-encoding';
5
5
  import { SimpleError } from '@simonbackx/simple-errors';
6
- import { EmailRecipient } from '@stamhoofd/models';
6
+ import { EmailRecipient, fillRecipientReplacements } from '@stamhoofd/models';
7
7
  import { applySQLSorter, compileToSQLFilter, SQLFilterDefinitions, SQLSortDefinitions } from '@stamhoofd/sql';
8
8
  import { Context } from '../../../helpers/Context';
9
9
  import { emailRecipientsFilterCompilers } from '../../../sql-filters/email-recipients';
@@ -126,7 +126,18 @@ export class GetEmailRecipientsEndpoint extends Endpoint<Params, Query, Body, Re
126
126
  }
127
127
 
128
128
  return new PaginatedResponse<EmailRecipientStruct[], LimitedFilteredRequest>({
129
- results: recipients.map(r => r.getStructure()),
129
+ results: await Promise.all((await EmailRecipient.getStructures(recipients)).map(async (r) => {
130
+ const rr = r.getRecipient();
131
+ await fillRecipientReplacements(rr, {
132
+ organization: Context.organization ?? null,
133
+ from: null,
134
+ replyTo: null,
135
+ forPreview: true,
136
+ forceRefresh: false,
137
+ });
138
+ r.replacements = rr.replacements;
139
+ return r;
140
+ })),
130
141
  next,
131
142
  });
132
143
  }
@@ -31,7 +31,7 @@ export class GetUserDetailedPayableBalanceEndpoint extends Endpoint<Params, Quer
31
31
  const organization = await Context.setUserOrganizationScope();
32
32
  const { user } = await Context.authenticate();
33
33
 
34
- const memberIds = await Member.getMemberIdsWithRegistrationForUser(user);
34
+ const memberIds = await Member.getMemberIdsForUser(user);
35
35
 
36
36
  const balanceItemModels = await BalanceItem.balanceItemsForUsersAndMembers(organization?.id ?? null, [user.id], memberIds);
37
37
 
@@ -92,9 +92,8 @@ export class PatchRegistrationPeriodsEndpoint extends Endpoint<Params, Query, Bo
92
92
  }
93
93
  }
94
94
 
95
- await period.setPreviousPeriodId();
96
-
97
95
  await period.save();
96
+ await period.updatePreviousNextPeriods();
98
97
  periods.push(period);
99
98
  }
100
99
 
@@ -138,7 +137,7 @@ export class PatchRegistrationPeriodsEndpoint extends Endpoint<Params, Query, Bo
138
137
  model.settings = patchObject(model.settings, patch.settings);
139
138
  }
140
139
 
141
- await model.setPreviousPeriodId();
140
+ await model.updatePreviousNextPeriods();
142
141
  await model.save();
143
142
 
144
143
  // Schedule patch of all groups in this period
@@ -156,23 +155,13 @@ export class PatchRegistrationPeriodsEndpoint extends Endpoint<Params, Query, Bo
156
155
  });
157
156
  }
158
157
 
159
- // Get before deleting the model
160
- const updateWhere = await RegistrationPeriod.where({ previousPeriodId: model.id });
161
-
162
158
  // Now delete the model
163
159
  await model.delete();
164
-
165
- // Update all previous period ids
166
- await AuditLogService.setContext({ source: AuditLogSource.System }, async () => {
167
- for (const period of updateWhere) {
168
- await period.setPreviousPeriodId();
169
- await period.save();
170
- }
171
- });
160
+ await RegistrationPeriod.updatePreviousNextPeriods(model.organizationId);
172
161
  }
173
162
 
174
163
  // Clear platform cache
175
- Platform.clearCache();
164
+ await Platform.clearCache();
176
165
 
177
166
  return new Response(
178
167
  periods.map(p => p.getStructure()),
@@ -1,5 +1,5 @@
1
1
  import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
2
- import { GroupPrivateSettings, Group as GroupStruct, GroupType, OrganizationRegistrationPeriod as OrganizationRegistrationPeriodStruct, PermissionLevel, PermissionsResourceType, ResourcePermissions, SetupStepType, Version } from '@stamhoofd/structures';
2
+ import { GroupPrivateSettings, Group as GroupStruct, GroupType, OrganizationRegistrationPeriod as OrganizationRegistrationPeriodStruct, PermissionLevel, PermissionsResourceType, ResourcePermissions, Version } from '@stamhoofd/structures';
3
3
 
4
4
  import { AutoEncoderPatchType, Decoder, PatchableArrayAutoEncoder, PatchableArrayDecoder, StringDecoder } from '@simonbackx/simple-encoding';
5
5
  import { SimpleError } from '@simonbackx/simple-errors';
@@ -407,12 +407,18 @@ export class PatchOrganizationRegistrationPeriodsEndpoint extends Endpoint<Param
407
407
  model.settings.period = period.getBaseStructure();
408
408
 
409
409
  if (model.type !== GroupType.EventRegistration) {
410
- // Note: start date is curomizable, as long as it stays between period start and end
411
- if (model.settings.startDate < period.startDate || model.settings.startDate > period.endDate) {
412
- model.settings.startDate = period.startDate;
410
+ if (!model.settings.hasCustomDates) {
411
+ model.settings.endDate = period.endDate;
412
+
413
+ // Note: start date is customizable, as long as it stays between period start and end
414
+ if (model.settings.startDate < period.startDate || model.settings.startDate > period.endDate) {
415
+ model.settings.startDate = period.startDate;
416
+ }
413
417
  }
414
418
 
415
- model.settings.endDate = period.endDate;
419
+ if (model.settings.startDate > model.settings.endDate) {
420
+ model.settings.startDate = model.settings.endDate;
421
+ }
416
422
  }
417
423
  }
418
424
 
@@ -530,13 +536,23 @@ export class PatchOrganizationRegistrationPeriodsEndpoint extends Endpoint<Param
530
536
  model.status = struct.status;
531
537
  model.type = struct.type;
532
538
  model.settings.period = period.getBaseStructure();
533
- model.settings.endDate = period.endDate;
534
539
 
535
- // Note: start date is curomizable, as long as it stays between period start and end
536
- if (model.settings.startDate < period.startDate || model.settings.startDate > period.endDate) {
537
- model.settings.startDate = period.startDate;
540
+ if (!model.settings.hasCustomDates) {
541
+ model.settings.endDate = period.endDate;
542
+
543
+ // Note: start date is customizable, as long as it stays between period start and end
544
+ if (model.settings.startDate < period.startDate || model.settings.startDate > period.endDate) {
545
+ model.settings.startDate = period.startDate;
546
+ }
547
+ }
548
+
549
+ if (model.settings.startDate > model.settings.endDate) {
550
+ model.settings.startDate = model.settings.endDate;
538
551
  }
539
552
 
553
+ model.settings.registeredMembers = 0;
554
+ model.settings.reservedMembers = 0;
555
+
540
556
  if (!await Context.auth.canAccessGroup(model, PermissionLevel.Full)) {
541
557
  // Create a temporary permission role for this user
542
558
  const organizationPermissions = user.permissions?.organizationPermissions?.get(organizationId);
@@ -883,6 +883,10 @@ export class AdminPermissionChecker {
883
883
  return false;
884
884
  }
885
885
 
886
+ if (email.deletedAt) {
887
+ return false;
888
+ }
889
+
886
890
  if (email.userId === this.user.id) {
887
891
  // User can always read their own emails
888
892
  // Note; for sending we'll always need to use 'canSendEmailsFrom' externally
@@ -604,7 +604,7 @@ export class AuthenticatedStructures {
604
604
 
605
605
  const registration = memberBlob.registrations.find(r => r.id === id);
606
606
  if (!registration) {
607
- throw new Error('Registration not found');
607
+ throw new Error('Registration not found: ' + id);
608
608
  }
609
609
 
610
610
  return RegistrationWithMemberBlob.create({