@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.
- package/package.json +10 -10
- package/src/audit-logs/EmailLogger.ts +7 -1
- package/src/audit-logs/ModelLogger.ts +17 -2
- package/src/crons/balance-emails.ts +232 -0
- package/src/crons/index.ts +1 -0
- package/src/email-recipient-loaders/members.ts +14 -4
- package/src/email-recipient-loaders/receivable-balances.ts +29 -15
- package/src/endpoints/admin/memberships/GetChargeMembershipsSummaryEndpoint.ts +47 -12
- package/src/endpoints/global/email/CreateEmailEndpoint.ts +2 -18
- package/src/endpoints/organization/dashboard/email-templates/GetEmailTemplatesEndpoint.ts +15 -1
- package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +1 -0
- package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalanceEndpoint.ts +1 -1
- package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalancesEndpoint.ts +0 -10
- package/src/helpers/EmailResumer.ts +1 -5
- package/src/helpers/MemberUserSyncer.ts +22 -1
- package/src/helpers/MembershipCharger.ts +1 -0
- package/src/helpers/TagHelper.ts +7 -14
- package/src/seeds/1728928974-update-cached-outstanding-balance-from-items.ts +4 -14
- package/src/seeds/1729253172-update-orders.ts +7 -18
- package/src/seeds/{1726494420-update-cached-outstanding-balance-from-items.ts → 1735577912-update-cached-outstanding-balance-from-items.ts} +1 -14
- package/src/seeds/1736266448-recall-balance-item-price-paid.ts +70 -0
- package/src/services/BalanceItemPaymentService.ts +14 -2
- package/src/services/BalanceItemService.ts +41 -1
- package/src/services/PlatformMembershipService.ts +5 -5
- package/src/sql-filters/members.ts +1 -0
- package/src/sql-filters/receivable-balances.ts +15 -1
- package/src/sql-filters/shared/EmailRelationFilterCompilers.ts +19 -0
- package/src/endpoints/organization/dashboard/email/EmailEndpoint.ts +0 -253
- package/src/helpers/ModelHelper.ts +0 -32
- package/src/seeds/1726055544-balance-item-paid.ts +0 -11
- package/src/seeds/1726055545-balance-item-pending.ts +0 -11
- package/src/seeds/1726494419-update-cached-outstanding-balance.ts +0 -53
- package/src/seeds/1728928973-balance-item-pending.ts +0 -11
- 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
|
-
});
|