@stamhoofd/backend 2.64.0 → 2.65.1

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.
Files changed (34) hide show
  1. package/package.json +10 -10
  2. package/src/audit-logs/EmailLogger.ts +7 -1
  3. package/src/audit-logs/ModelLogger.ts +17 -2
  4. package/src/crons/balance-emails.ts +232 -0
  5. package/src/crons/index.ts +1 -0
  6. package/src/email-recipient-loaders/members.ts +14 -4
  7. package/src/email-recipient-loaders/receivable-balances.ts +29 -15
  8. package/src/endpoints/admin/memberships/GetChargeMembershipsSummaryEndpoint.ts +47 -12
  9. package/src/endpoints/global/email/CreateEmailEndpoint.ts +2 -18
  10. package/src/endpoints/organization/dashboard/email-templates/GetEmailTemplatesEndpoint.ts +15 -1
  11. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +1 -0
  12. package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalanceEndpoint.ts +1 -1
  13. package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalancesEndpoint.ts +0 -10
  14. package/src/helpers/EmailResumer.ts +1 -5
  15. package/src/helpers/MemberUserSyncer.ts +22 -1
  16. package/src/helpers/MembershipCharger.ts +1 -0
  17. package/src/helpers/TagHelper.ts +7 -14
  18. package/src/seeds/1728928974-update-cached-outstanding-balance-from-items.ts +4 -14
  19. package/src/seeds/1729253172-update-orders.ts +7 -18
  20. package/src/seeds/{1726494420-update-cached-outstanding-balance-from-items.ts → 1735577912-update-cached-outstanding-balance-from-items.ts} +1 -14
  21. package/src/seeds/1736266448-recall-balance-item-price-paid.ts +70 -0
  22. package/src/services/BalanceItemPaymentService.ts +14 -2
  23. package/src/services/BalanceItemService.ts +41 -1
  24. package/src/services/PlatformMembershipService.ts +5 -5
  25. package/src/sql-filters/members.ts +1 -0
  26. package/src/sql-filters/receivable-balances.ts +15 -1
  27. package/src/sql-filters/shared/EmailRelationFilterCompilers.ts +19 -0
  28. package/src/endpoints/organization/dashboard/email/EmailEndpoint.ts +0 -253
  29. package/src/helpers/ModelHelper.ts +0 -32
  30. package/src/seeds/1726055544-balance-item-paid.ts +0 -11
  31. package/src/seeds/1726055545-balance-item-pending.ts +0 -11
  32. package/src/seeds/1726494419-update-cached-outstanding-balance.ts +0 -53
  33. package/src/seeds/1728928973-balance-item-pending.ts +0 -11
  34. package/src/seeds/1734700082-update-cached-outstanding-balance-from-items.ts +0 -40
@@ -1,253 +0,0 @@
1
- import { Decoder } from '@simonbackx/simple-encoding';
2
- import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
3
- import { SimpleError } from '@simonbackx/simple-errors';
4
- import { Email } from '@stamhoofd/email';
5
- import { getEmailBuilder, RateLimiter } from '@stamhoofd/models';
6
- import { EmailRequest, Recipient } from '@stamhoofd/structures';
7
-
8
- import { Context } from '../../../../helpers/Context';
9
-
10
- type Params = Record<string, never>;
11
- type Query = undefined;
12
- type Body = EmailRequest;
13
- type ResponseBody = undefined;
14
-
15
- export const paidEmailRateLimiter = new RateLimiter({
16
- limits: [
17
- {
18
- // Max 5.000 emails a day
19
- limit: 5000,
20
- duration: 24 * 60 * 1000 * 60,
21
- },
22
- {
23
- // 10.000 requests per week
24
- limit: 10000,
25
- duration: 24 * 60 * 1000 * 60 * 7,
26
- },
27
- ],
28
- });
29
-
30
- export const freeEmailRateLimiter = new RateLimiter({
31
- limits: [
32
- {
33
- // Max 100 a day
34
- limit: 100,
35
- duration: 24 * 60 * 1000 * 60,
36
- },
37
- {
38
- // Max 200 a week
39
- limit: 200,
40
- duration: 7 * 24 * 60 * 1000 * 60,
41
- },
42
- ],
43
- });
44
-
45
- /**
46
- * One endpoint to create, patch and delete groups. Usefull because on organization setup, we need to create multiple groups at once. Also, sometimes we need to link values and update multiple groups at once
47
- */
48
-
49
- export class EmailEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
50
- bodyDecoder = EmailRequest as Decoder<EmailRequest>;
51
-
52
- protected doesMatch(request: Request): [true, Params] | [false] {
53
- if (request.method !== 'POST') {
54
- return [false];
55
- }
56
-
57
- const params = Endpoint.parseParameters(request.url, '/email/legacy', {});
58
-
59
- if (params) {
60
- return [true, params as Params];
61
- }
62
- return [false];
63
- }
64
-
65
- async handle(request: DecodedRequest<Params, Query, Body>) {
66
- const organization = await Context.setOrganizationScope();
67
- const { user } = await Context.authenticate();
68
-
69
- if (!Context.auth.canSendEmails()) {
70
- throw Context.auth.error();
71
- }
72
-
73
- if (request.body.recipients.length > 5000) {
74
- throw new SimpleError({
75
- code: 'too_many_recipients',
76
- message: 'Too many recipients',
77
- human: 'Je kan maar een mail naar maximaal 5000 personen tergelijk versturen. Contacteer ons om deze limiet te verhogen indien dit nodig is.',
78
- field: 'recipients',
79
- });
80
- }
81
-
82
- // For non paid organizations, the limit is 10
83
- if (request.body.recipients.length > 10 && !organization.meta.packages.isPaid) {
84
- throw new SimpleError({
85
- code: 'too_many_emails',
86
- message: 'Too many e-mails',
87
- human: 'Zolang je de demo versie van Stamhoofd gebruikt kan je maar maximaal een email sturen naar 10 emailadressen. Als je het pakket aankoopt zal deze limiet er niet zijn. Dit is om misbruik te voorkomen met spammers die spam email versturen via Stamhoofd.',
88
- field: 'recipients',
89
- });
90
- }
91
-
92
- const limiter = organization.meta.packages.isPaid ? paidEmailRateLimiter : freeEmailRateLimiter;
93
-
94
- try {
95
- limiter.track(organization.id, request.body.recipients.length);
96
- }
97
- catch (e) {
98
- Email.sendWebmaster({
99
- subject: '[Limiet] Limiet bereikt voor aantal e-mails',
100
- text: 'Beste, \nDe limiet werd bereikt voor het aantal e-mails per dag. \nVereniging: ' + organization.id + ' (' + organization.name + ')' + '\n\n' + e.message + '\n\nStamhoofd',
101
- });
102
-
103
- throw new SimpleError({
104
- code: 'too_many_emails_period',
105
- message: 'Too many e-mails limited',
106
- human: 'Oeps! Om spam te voorkomen limiteren we het aantal emails die je per dag/week kan versturen. Neem contact met ons op om deze limiet te verhogen.',
107
- field: 'recipients',
108
- });
109
- }
110
-
111
- // Validate email
112
- const sender = organization.privateMeta.emails.find(e => e.id == request.body.emailId);
113
- if (!sender) {
114
- throw new SimpleError({
115
- code: 'invalid_field',
116
- message: 'Invalid emailId',
117
- human: 'Het e-mailadres waarvan je wilt versturen bestaat niet (meer). Kijk je het na?',
118
- field: 'emailId',
119
- });
120
- }
121
-
122
- // Validate attachments
123
- const size = request.body.attachments.reduce((value: number, attachment) => {
124
- return value + attachment.content.length;
125
- }, 0);
126
-
127
- if (size > 9.5 * 1024 * 1024) {
128
- throw new SimpleError({
129
- code: 'too_big_attachments',
130
- message: 'Too big attachments',
131
- human: 'Jouw bericht is te groot. Grote bijlages verstuur je beter niet via e-mail, je plaatst dan best een link naar de locatie in bv. Google Drive. De maximale grootte van een e-mail is 10MB, inclusief het bericht. Als je grote bestanden verstuurt kan je ze ook proberen te verkleinen.',
132
- field: 'attachments',
133
- });
134
- }
135
-
136
- const safeContentTypes = [
137
- 'application/msword',
138
- 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
139
- 'application/vnd.ms-excel',
140
- 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
141
- 'application/pdf',
142
- 'image/jpeg',
143
- 'image/png',
144
- 'image/gif',
145
- ];
146
-
147
- for (const attachment of request.body.attachments) {
148
- if (attachment.contentType && !safeContentTypes.includes(attachment.contentType)) {
149
- throw new SimpleError({
150
- code: 'content_type_not_supported',
151
- message: 'Content-Type not supported',
152
- human: 'Het bestandstype van jouw bijlage wordt niet ondersteund of is onveilig om in een e-mail te plaatsen. Overweeg om je bestand op bv. Google Drive te zetten en de link in jouw e-mail te zetten.',
153
- field: 'attachments',
154
- });
155
- }
156
- }
157
-
158
- const attachments = request.body.attachments.map((attachment, index) => {
159
- let filename = 'bijlage-' + index;
160
-
161
- if (attachment.contentType == 'application/pdf') {
162
- // tmp solution for pdf only
163
- filename += '.pdf';
164
- }
165
-
166
- // Correct file name if needed
167
- if (attachment.filename) {
168
- filename = attachment.filename.toLowerCase().replace(/[^a-z0-9.]+/g, '-').replace(/^-+/, '').replace(/-+$/, '');
169
- }
170
-
171
- return {
172
- filename: filename,
173
- content: attachment.content,
174
- contentType: attachment.contentType ?? undefined,
175
- encoding: 'base64',
176
- };
177
- });
178
-
179
- let from = organization.getDefaultFrom(request.i18n, false, 'broadcast');
180
- let replyTo: string | undefined = sender.email;
181
-
182
- // Can we send from this e-mail or reply-to?
183
- if (organization.privateMeta.mailDomain && organization.privateMeta.mailDomainActive && sender.email.endsWith('@' + organization.privateMeta.mailDomain)) {
184
- from = sender.email;
185
- replyTo = undefined;
186
- }
187
-
188
- // Include name in form field
189
- if (sender.name) {
190
- from = '"' + sender.name.replaceAll('"', '\\"') + '" <' + from + '>';
191
- }
192
- else {
193
- from = '"' + organization.name.replaceAll('"', '\\"') + '" <' + from + '>';
194
- }
195
-
196
- const email = request.body;
197
-
198
- if (!email.html) {
199
- throw new SimpleError({
200
- code: 'missing_field',
201
- message: 'Missing html',
202
- human: 'Je hebt geen inhoud ingevuld voor je e-mail. Vul een bericht in en probeer opnieuw.',
203
- field: 'html',
204
- });
205
- }
206
-
207
- if (!email.subject) {
208
- throw new SimpleError({
209
- code: 'missing_field',
210
- message: 'Missing subject',
211
- human: 'Je hebt geen onderwerp ingevuld voor je e-mail. Vul een onderwerp in en probeer opnieuw.',
212
- field: 'subject',
213
- });
214
- }
215
-
216
- // Create e-mail builder
217
- const builder = await getEmailBuilder(organization, {
218
- subject: email.subject,
219
- html: email.html,
220
- recipients: email.recipients,
221
- from,
222
- replyTo,
223
- attachments,
224
- defaultReplacements: request.body.defaultReplacements ?? [],
225
- });
226
-
227
- Email.schedule(builder);
228
-
229
- // Also send a copy
230
- const recipient = Recipient.create(email.recipients[0]);
231
- recipient.email = sender.email;
232
- recipient.firstName = sender.name ?? null;
233
- recipient.lastName = null;
234
- recipient.userId = null;
235
-
236
- const prefix = '<p><i>Kopie e-mail verzonden door ' + user.firstName + ' ' + user.lastName + '</i><br /><br /></p>';
237
- const builder2 = await getEmailBuilder(organization, {
238
- ...email,
239
- subject: '[KOPIE] ' + email.subject,
240
- html: email.html.replace('<body>', '<body>' + prefix),
241
- recipients: [
242
- recipient,
243
- ],
244
- from,
245
- replyTo,
246
- attachments,
247
- });
248
-
249
- Email.schedule(builder2);
250
-
251
- return new Response(undefined);
252
- }
253
- }
@@ -1,32 +0,0 @@
1
- import { Model } from '@simonbackx/simple-database';
2
-
3
- // todo: move for reuse?
4
- type KeysMatching<T, V> = { [K in keyof T]-?: T[K] extends V ? K : never }[keyof T];
5
-
6
- export class ModelHelper {
7
- static async loop<M extends typeof Model>(m: M, idKey: KeysMatching<InstanceType<M>, string> & string, onBatchReceived: (batch: InstanceType<M>[]) => Promise<void>, options: { limit?: number } = {}) {
8
- let lastId = '';
9
- const limit = options.limit ?? 10;
10
-
11
- while (true) {
12
- const models = await m.where(
13
- { [idKey]: { sign: '>', value: lastId } },
14
- { limit, sort: [idKey] });
15
-
16
- if (models.length === 0) {
17
- break;
18
- }
19
-
20
- await onBatchReceived(models);
21
-
22
- if (models.length < limit) {
23
- break;
24
- }
25
-
26
- lastId
27
- = models[
28
- models.length - 1
29
- ][idKey] as string;
30
- }
31
- }
32
- }
@@ -1,11 +0,0 @@
1
- import { Migration } from '@simonbackx/simple-database';
2
- import { BalanceItem } from '@stamhoofd/models';
3
-
4
- export default new Migration(async () => {
5
- if (STAMHOOFD.environment == 'test') {
6
- console.log('skipped in tests');
7
- return;
8
- }
9
-
10
- await BalanceItem.updatePricePaid('all');
11
- });
@@ -1,11 +0,0 @@
1
- import { Migration } from '@simonbackx/simple-database';
2
- import { BalanceItem } from '@stamhoofd/models';
3
-
4
- export default new Migration(async () => {
5
- if (STAMHOOFD.environment == 'test') {
6
- console.log('skipped in tests');
7
- return;
8
- }
9
-
10
- await BalanceItem.updatePricePending('all');
11
- });
@@ -1,53 +0,0 @@
1
- import { Migration } from '@simonbackx/simple-database';
2
- import { logger } from '@simonbackx/simple-logging';
3
- import { BalanceItem, BalanceItemPayment, Payment } from '@stamhoofd/models';
4
-
5
- export default new Migration(async () => {
6
- if (STAMHOOFD.environment == 'test') {
7
- console.log('skipped in tests');
8
- return;
9
- }
10
-
11
- process.stdout.write('\n');
12
- let c = 0;
13
- let id: string = '';
14
-
15
- await logger.setContext({ tags: ['silent-seed', 'seed'] }, async () => {
16
- while (true) {
17
- const payments = await Payment.where({
18
- id: {
19
- value: id,
20
- sign: '>',
21
- },
22
- }, { limit: 100, sort: ['id'] });
23
-
24
- for (const payment of payments) {
25
- const unloaded = (await BalanceItemPayment.where({ paymentId: payment.id })).map(r => r.setRelation(BalanceItemPayment.payment, payment));
26
- const balanceItemPayments = await BalanceItemPayment.balanceItem.load(
27
- unloaded,
28
- );
29
-
30
- await BalanceItem.updateOutstanding(balanceItemPayments.map(p => p.balanceItem));
31
-
32
- c++;
33
-
34
- if (c % 100 === 0) {
35
- process.stdout.write('.');
36
- }
37
- if (c % 10000 === 0) {
38
- process.stdout.write('\n');
39
- }
40
- }
41
-
42
- if (payments.length < 100) {
43
- break;
44
- }
45
- id = payments[payments.length - 1].id;
46
- }
47
- });
48
-
49
- console.log('Updated outstanding balance for ' + c + ' payments');
50
-
51
- // Do something here
52
- return Promise.resolve();
53
- });
@@ -1,11 +0,0 @@
1
- import { Migration } from '@simonbackx/simple-database';
2
- import { BalanceItem } from '@stamhoofd/models';
3
-
4
- export default new Migration(async () => {
5
- if (STAMHOOFD.environment == 'test') {
6
- console.log('skipped in tests');
7
- return;
8
- }
9
-
10
- await BalanceItem.updatePricePending('all');
11
- });
@@ -1,40 +0,0 @@
1
- import { Migration } from '@simonbackx/simple-database';
2
- import { logger } from '@simonbackx/simple-logging';
3
- import { BalanceItem } from '@stamhoofd/models';
4
-
5
- export default new Migration(async () => {
6
- if (STAMHOOFD.environment == 'test') {
7
- console.log('skipped in tests');
8
- return;
9
- }
10
-
11
- process.stdout.write('\n');
12
- let c = 0;
13
- let id: string = '';
14
-
15
- await logger.setContext({ tags: ['silent-seed', 'seed'] }, async () => {
16
- while (true) {
17
- const items = await BalanceItem.where({
18
- id: {
19
- value: id,
20
- sign: '>',
21
- },
22
- }, { limit: 1000, sort: ['id'] });
23
-
24
- await BalanceItem.updateOutstanding(items);
25
-
26
- c += items.length;
27
- process.stdout.write('.');
28
-
29
- if (items.length < 1000) {
30
- break;
31
- }
32
- id = items[items.length - 1].id;
33
- }
34
- });
35
-
36
- console.log('Updated outstanding balance for ' + c + ' items');
37
-
38
- // Do something here
39
- return Promise.resolve();
40
- });