@stamhoofd/models 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/dist/src/factories/RegistrationPeriodFactory.d.ts +1 -0
- package/dist/src/factories/RegistrationPeriodFactory.d.ts.map +1 -1
- package/dist/src/factories/RegistrationPeriodFactory.js +4 -0
- package/dist/src/factories/RegistrationPeriodFactory.js.map +1 -1
- package/dist/src/helpers/EmailBuilder.d.ts +27 -1
- package/dist/src/helpers/EmailBuilder.d.ts.map +1 -1
- package/dist/src/helpers/EmailBuilder.js +270 -25
- package/dist/src/helpers/EmailBuilder.js.map +1 -1
- package/dist/src/migrations/1756115317-email-deleted-at.sql +2 -0
- package/dist/src/migrations/1756293494-registration-period-next-period-id.sql +3 -0
- package/dist/src/migrations/1756293495-platform-next-period-id.sql +3 -0
- package/dist/src/migrations/1756387016-registration-period-custom-name.sql +2 -0
- package/dist/src/migrations/1756391212-email-recipients-duplicate.sql +3 -0
- package/dist/src/models/Email.d.ts +4 -2
- package/dist/src/models/Email.d.ts.map +1 -1
- package/dist/src/models/Email.js +160 -36
- package/dist/src/models/Email.js.map +1 -1
- package/dist/src/models/EmailRecipient.d.ts +7 -1
- package/dist/src/models/EmailRecipient.d.ts.map +1 -1
- package/dist/src/models/EmailRecipient.js +28 -2
- package/dist/src/models/EmailRecipient.js.map +1 -1
- package/dist/src/models/Event.d.ts.map +1 -1
- package/dist/src/models/Event.js +2 -0
- package/dist/src/models/Event.js.map +1 -1
- package/dist/src/models/Member.d.ts +1 -1
- package/dist/src/models/Member.d.ts.map +1 -1
- package/dist/src/models/Member.js +3 -3
- package/dist/src/models/Member.js.map +1 -1
- package/dist/src/models/Platform.d.ts +1 -0
- package/dist/src/models/Platform.d.ts.map +1 -1
- package/dist/src/models/Platform.js +5 -0
- package/dist/src/models/Platform.js.map +1 -1
- package/dist/src/models/RegistrationPeriod.d.ts +4 -1
- package/dist/src/models/RegistrationPeriod.d.ts.map +1 -1
- package/dist/src/models/RegistrationPeriod.js +32 -7
- package/dist/src/models/RegistrationPeriod.js.map +1 -1
- package/dist/src/models/User.d.ts.map +1 -1
- package/dist/src/models/User.js +5 -1
- package/dist/src/models/User.js.map +1 -1
- package/package.json +2 -2
- package/src/factories/RegistrationPeriodFactory.ts +4 -0
- package/src/helpers/EmailBuilder.ts +282 -34
- package/src/migrations/1756115317-email-deleted-at.sql +2 -0
- package/src/migrations/1756293494-registration-period-next-period-id.sql +3 -0
- package/src/migrations/1756293495-platform-next-period-id.sql +3 -0
- package/src/migrations/1756387016-registration-period-custom-name.sql +2 -0
- package/src/migrations/1756391212-email-recipients-duplicate.sql +3 -0
- package/src/models/Email.ts +184 -43
- package/src/models/EmailRecipient.ts +32 -3
- package/src/models/Event.ts +2 -0
- package/src/models/Member.ts +3 -3
- package/src/models/Platform.ts +4 -0
- package/src/models/RegistrationPeriod.ts +32 -7
- package/src/models/User.ts +7 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Email, EmailAddress, EmailBuilder, EmailInterfaceRecipient } from '@stamhoofd/email';
|
|
2
|
-
import { BalanceItem as BalanceItemStruct, EmailTemplateType, OrganizationEmail, Platform as PlatformStruct, ReceivableBalanceType, Recipient, replaceEmailHtml, replaceEmailText, Replacement } from '@stamhoofd/structures';
|
|
2
|
+
import { BalanceItem as BalanceItemStruct, EmailRecipient, EmailTemplateType, OrganizationEmail, Platform as PlatformStruct, ReceivableBalanceType, Recipient, replaceEmailHtml, replaceEmailText, Replacement } from '@stamhoofd/structures';
|
|
3
3
|
import { Formatter } from '@stamhoofd/utility';
|
|
4
4
|
|
|
5
5
|
import { SimpleError } from '@simonbackx/simple-errors';
|
|
@@ -379,58 +379,306 @@ export async function getEmailBuilder(organization: Organization | null, email:
|
|
|
379
379
|
return builder;
|
|
380
380
|
}
|
|
381
381
|
|
|
382
|
+
export function mergeReplacement(replacementA: Replacement, replacementB: Replacement): Replacement | false {
|
|
383
|
+
if (replacementA.token !== replacementB.token) {
|
|
384
|
+
return false;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (replacementA.token === 'greeting') {
|
|
388
|
+
// Just take the first one
|
|
389
|
+
return replacementA;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (replacementA.token === 'unsubscribeUrl') {
|
|
393
|
+
return replacementA;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (replacementA.token === 'signInUrl') {
|
|
397
|
+
return replacementA;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (replacementA.token === 'loginDetails') {
|
|
401
|
+
// loginDetails are always the same for the same user.
|
|
402
|
+
return replacementA;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return false;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Remove duplicates
|
|
410
|
+
*/
|
|
411
|
+
export function cleanReplacements(replacements: Replacement[]) {
|
|
412
|
+
const foundIds: Set<string> = new Set();
|
|
413
|
+
const cleaned: Replacement[] = [];
|
|
414
|
+
for (const r of replacements) {
|
|
415
|
+
if (foundIds.has(r.token)) {
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
foundIds.add(r.token);
|
|
419
|
+
cleaned.push(r);
|
|
420
|
+
}
|
|
421
|
+
return cleaned;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export function removeUnusedReplacements(html: string, replacements: Replacement[]) {
|
|
425
|
+
const cleaned: Replacement[] = [];
|
|
426
|
+
for (const r of replacements) {
|
|
427
|
+
if (html.includes(`{{${r.token}}}`)) {
|
|
428
|
+
cleaned.push(r);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return cleaned;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export function mergeReplacementsIfEqual(replacementsA: Replacement[], replacementsB: Replacement[]): Replacement[] | false {
|
|
435
|
+
replacementsA = cleanReplacements(replacementsA);
|
|
436
|
+
replacementsB = cleanReplacements(replacementsB);
|
|
437
|
+
|
|
438
|
+
if (replacementsA.length !== replacementsB.length) {
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const merged: Replacement[] = [];
|
|
443
|
+
for (const rA of replacementsA) {
|
|
444
|
+
const rB = replacementsB.find(r => r.token === rA.token);
|
|
445
|
+
if (!rB) {
|
|
446
|
+
return false;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (rA.html === rB.html && rA.value === rB.value) {
|
|
450
|
+
merged.push(rA);
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const m = mergeReplacement(rA, rB);
|
|
455
|
+
if (!m) {
|
|
456
|
+
return false;
|
|
457
|
+
}
|
|
458
|
+
merged.push(m);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return merged;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Filter replacements for display in the backend.
|
|
466
|
+
* @param options.forPreview if true, it will hide sensitive information in the preview that could leak information to admin users
|
|
467
|
+
*/
|
|
468
|
+
export function stripSensitiveRecipientReplacements(recipient: Recipient | EmailRecipient, options: {
|
|
469
|
+
organization: Organization | null;
|
|
470
|
+
willFill?: boolean;
|
|
471
|
+
}) {
|
|
472
|
+
const { organization } = options;
|
|
473
|
+
// Remove unsubscribeUrl and signInUrl if present
|
|
474
|
+
recipient.replacements = recipient.replacements.filter(r => r.token !== 'unsubscribeUrl' && r.token !== 'signInUrl');
|
|
475
|
+
|
|
476
|
+
if (options.willFill) {
|
|
477
|
+
// Also strip loginDetails, balanceTable and outstandingBalance
|
|
478
|
+
recipient.replacements = recipient.replacements.filter(r => r.token !== 'balanceTable' && r.token !== 'outstandingBalance' && r.token !== 'loginDetails');
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Add dummy unsubscribeUrl
|
|
483
|
+
const dummyUnsubscribeUrl = 'https://' + (organization && STAMHOOFD.userMode === 'organization' ? organization.getHost() : STAMHOOFD.domains.dashboard) + '/unsubscribe?token=example';
|
|
484
|
+
recipient.replacements.push(Replacement.create({
|
|
485
|
+
token: 'unsubscribeUrl',
|
|
486
|
+
value: dummyUnsubscribeUrl,
|
|
487
|
+
}));
|
|
488
|
+
|
|
489
|
+
// dummy signInUrl
|
|
490
|
+
const dummySignInUrl = 'https://' + (organization && STAMHOOFD.userMode === 'organization' ? organization.getHost() : STAMHOOFD.domains.dashboard) + '/login';
|
|
491
|
+
recipient.replacements.push(Replacement.create({
|
|
492
|
+
token: 'signInUrl',
|
|
493
|
+
value: dummySignInUrl,
|
|
494
|
+
}));
|
|
495
|
+
|
|
496
|
+
// Strip security codes (because we list ALL security codes, also from members a viewer might not have access to)
|
|
497
|
+
recipient.replacements = recipient.replacements.map((r) => {
|
|
498
|
+
if (r.token !== 'loginDetails') {
|
|
499
|
+
return r;
|
|
500
|
+
}
|
|
501
|
+
return Replacement.create({
|
|
502
|
+
...r,
|
|
503
|
+
// Strip <span class="style-inline-code">(.*)</span> and replace content with XXXX-XXXX-XXXX-XXXX
|
|
504
|
+
html: r.html ? r.html.replace(/<span class="style-inline-code">.*?<\/span>/g, '<span class="style-inline-code">••••</span>') : r.html,
|
|
505
|
+
});
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Fill and hide replacements that don't make sense for web display to the user
|
|
511
|
+
*/
|
|
512
|
+
export function stripRecipientReplacementsForWebDisplay(recipient: Recipient | EmailRecipient, options: {
|
|
513
|
+
organization: Organization | null;
|
|
514
|
+
}) {
|
|
515
|
+
const { organization } = options;
|
|
516
|
+
// Remove unsubscribeUrl if present
|
|
517
|
+
recipient.replacements = recipient.replacements.filter(r => r.token !== 'unsubscribeUrl' && r.token !== 'loginDetails' && r.token !== 'greeting');
|
|
518
|
+
|
|
519
|
+
// Add dummy unsubscribeUrl
|
|
520
|
+
const dummyUnsubscribeUrl = 'https://' + (organization && STAMHOOFD.userMode === 'organization' ? organization.getHost() : STAMHOOFD.domains.dashboard);
|
|
521
|
+
recipient.replacements.push(Replacement.create({
|
|
522
|
+
token: 'unsubscribeUrl',
|
|
523
|
+
value: dummyUnsubscribeUrl,
|
|
524
|
+
}));
|
|
525
|
+
|
|
526
|
+
recipient.replacements.push(Replacement.create({
|
|
527
|
+
token: 'loginDetails',
|
|
528
|
+
value: '',
|
|
529
|
+
}));
|
|
530
|
+
|
|
531
|
+
recipient.replacements.push(Replacement.create({
|
|
532
|
+
token: 'greeting',
|
|
533
|
+
value: $t('Beste,'),
|
|
534
|
+
}));
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* @param options.forPreview if true, it will hide sensitive information in the preview that could leak information to admin users
|
|
539
|
+
*/
|
|
382
540
|
export async function fillRecipientReplacements(recipient: Recipient, options: {
|
|
383
541
|
organization: Organization | null;
|
|
384
542
|
platform?: PlatformStruct;
|
|
385
543
|
from: EmailInterfaceRecipient | null;
|
|
386
544
|
replyTo: EmailInterfaceRecipient | null;
|
|
545
|
+
forPreview?: boolean;
|
|
546
|
+
forceRefresh?: boolean;
|
|
387
547
|
}) {
|
|
388
548
|
if (!options.platform) {
|
|
389
549
|
options.platform = await Platform.getSharedPrivateStruct();
|
|
390
550
|
}
|
|
391
551
|
const { organization, platform, from, replyTo } = options;
|
|
552
|
+
let recipientUser: User | null | undefined = null;
|
|
392
553
|
recipient.replacements = recipient.replacements.slice();
|
|
554
|
+
if (options.forPreview) {
|
|
555
|
+
stripSensitiveRecipientReplacements(recipient, options);
|
|
556
|
+
}
|
|
393
557
|
|
|
394
|
-
|
|
395
|
-
|
|
558
|
+
if (!recipient.email && !recipient.userId) {
|
|
559
|
+
const signInUrl = 'https://' + (organization && STAMHOOFD.userMode === 'organization' ? organization.getHost() : STAMHOOFD.domains.dashboard) + '/login';
|
|
560
|
+
recipient.replacements.push(Replacement.create({
|
|
561
|
+
token: 'signInUrl',
|
|
562
|
+
value: signInUrl,
|
|
563
|
+
}));
|
|
396
564
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
565
|
+
if (!recipient.replacements.find(r => r.token === 'loginDetails')) {
|
|
566
|
+
recipient.replacements.push(Replacement.create({
|
|
567
|
+
token: 'loginDetails',
|
|
568
|
+
value: '',
|
|
569
|
+
}));
|
|
570
|
+
}
|
|
401
571
|
}
|
|
572
|
+
else {
|
|
573
|
+
// Default signInUrl
|
|
574
|
+
let signInUrl = 'https://' + (organization && STAMHOOFD.userMode === 'organization' ? organization.getHost() : STAMHOOFD.domains.dashboard) + '/login?email=' + encodeURIComponent(recipient.email);
|
|
575
|
+
|
|
576
|
+
recipientUser = recipient.userId ? await User.select().where('id', recipient.userId).first(false) : await User.getForAuthentication(organization?.id ?? null, recipient.email, { allowWithoutAccount: true });
|
|
577
|
+
if (STAMHOOFD.userMode !== 'platform' && recipientUser && recipientUser.organizationId && recipientUser.organizationId !== (organization?.id ?? null)) {
|
|
578
|
+
console.warn('User organization does not match current organization, ignoring userId', recipient.userId, recipientUser.organizationId, organization?.id ?? null);
|
|
579
|
+
recipientUser = null;
|
|
580
|
+
}
|
|
402
581
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
582
|
+
if (!recipientUser || !recipientUser.hasAccount()) {
|
|
583
|
+
// We can create a special token
|
|
584
|
+
signInUrl = 'https://' + (organization && STAMHOOFD.userMode === 'organization' ? organization.getHost() : STAMHOOFD.domains.dashboard) + '/account-aanmaken?email=' + encodeURIComponent(recipient.email);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
recipient.replacements.push(Replacement.create({
|
|
588
|
+
token: 'signInUrl',
|
|
589
|
+
value: signInUrl,
|
|
590
|
+
}));
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (options.forceRefresh) {
|
|
594
|
+
// Remove loginDetails to force refresh
|
|
595
|
+
recipient.replacements = recipient.replacements.filter(r => r.token !== 'loginDetails');
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (!recipient.replacements.find(r => r.token === 'loginDetails')) {
|
|
599
|
+
const emailEscaped = `<strong>${Formatter.escapeHtml(recipient.email)}</strong>`;
|
|
600
|
+
|
|
601
|
+
if (recipientUser) {
|
|
602
|
+
const suffixes: string[] = [];
|
|
603
|
+
if (STAMHOOFD.userMode === 'platform') {
|
|
604
|
+
const { Member } = await import('../models/Member');
|
|
605
|
+
const memberIds = await Member.getMemberIdsForUser(recipientUser);
|
|
606
|
+
const members = await Member.getByIDs(...memberIds);
|
|
607
|
+
if (members.length > 0) {
|
|
608
|
+
for (const member of members) {
|
|
609
|
+
suffixes.push(
|
|
610
|
+
$t('De beveiligingscode voor {firstName} is {securityCode}.', {
|
|
611
|
+
firstName: Formatter.escapeHtml(member.firstName),
|
|
612
|
+
securityCode: `<span class="style-inline-code">${Formatter.escapeHtml(options.forPreview ? '••••' : Formatter.spaceString(member.details.securityCode ?? '', 4, '-'))}</span>`,
|
|
613
|
+
}),
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
else {
|
|
618
|
+
console.log('No member found for user', recipientUser.id);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
const suffix = suffixes.length > 0 ? (' ' + suffixes.join(' ')) : '';
|
|
622
|
+
recipient.replacements.push(
|
|
623
|
+
Replacement.create({
|
|
624
|
+
token: 'loginDetails',
|
|
625
|
+
value: '',
|
|
626
|
+
html: recipientUser.hasAccount()
|
|
627
|
+
? `<p class="description"><em>${$t('Je kan op het ledenportaal inloggen op {email}.', { email: emailEscaped })}${suffix}</em></p>`
|
|
628
|
+
: `<p class="description"><em>${$t('Je kan op het ledenportaal een nieuw account aanmaken op het e-mailadres {email}.', { email: emailEscaped })}${suffix}</em></p>`,
|
|
629
|
+
}),
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
else {
|
|
633
|
+
console.log('No user found for email', recipient.email);
|
|
634
|
+
recipient.replacements.push(
|
|
635
|
+
Replacement.create({
|
|
636
|
+
token: 'loginDetails',
|
|
637
|
+
value: '',
|
|
638
|
+
html: `<p class="description"><em>${$t('Je kan op het ledenportaal een nieuw account aanmaken op het e-mailadres {email}', { email: emailEscaped })}</em></p>`,
|
|
639
|
+
}),
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
407
643
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
html: recipientUser && recipientUser.hasAccount() ? `<p class="description"><em>${$t('2fa762f2-c061-4c40-83cb-6ddc3e5f0f7a')} <strong>${Formatter.escapeHtml(recipientUser.email)}</strong></em></p>` : `<p class="description"><em>${$t('c2af5148-15a7-44b1-aa3e-91cfc4c66013')} <strong>${Formatter.escapeHtml(recipient.email)}</strong>${$t('f3aa8253-d88e-41c7-8c98-ed477806c533')}</em></p>`,
|
|
413
|
-
}),
|
|
414
|
-
);
|
|
644
|
+
if (options.forceRefresh) {
|
|
645
|
+
// Remove loginDetails to force refresh
|
|
646
|
+
recipient.replacements = recipient.replacements.filter(r => r.token !== 'balanceTable' && r.token !== 'outstandingBalance');
|
|
647
|
+
}
|
|
415
648
|
|
|
416
649
|
// Load balance of this user
|
|
417
650
|
// todo: only if detected it is used
|
|
418
|
-
if (
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
651
|
+
if (!recipient.replacements.find(r => r.token === 'balanceTable')) {
|
|
652
|
+
if (organization && recipientUser) {
|
|
653
|
+
const balanceItemModels = await CachedBalance.balanceForObjects(organization.id, [recipientUser.id], ReceivableBalanceType.user, true);
|
|
654
|
+
const balanceItems = balanceItemModels.map(i => i.getStructure());
|
|
655
|
+
|
|
656
|
+
// Get members
|
|
657
|
+
recipient.replacements.push(
|
|
658
|
+
Replacement.create({
|
|
659
|
+
token: 'outstandingBalance',
|
|
660
|
+
value: Formatter.price(balanceItems.reduce((sum, i) => sum + i.priceOpen, 0)),
|
|
661
|
+
}),
|
|
662
|
+
Replacement.create({
|
|
663
|
+
token: 'balanceTable',
|
|
664
|
+
value: '',
|
|
665
|
+
html: BalanceItemStruct.getDetailsHTMLTable(balanceItems),
|
|
666
|
+
}),
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
else {
|
|
670
|
+
recipient.replacements.push(
|
|
671
|
+
Replacement.create({
|
|
672
|
+
token: 'outstandingBalance',
|
|
673
|
+
value: Formatter.price(0),
|
|
674
|
+
}),
|
|
675
|
+
Replacement.create({
|
|
676
|
+
token: 'balanceTable',
|
|
677
|
+
value: '',
|
|
678
|
+
html: BalanceItemStruct.getDetailsHTMLTable([]),
|
|
679
|
+
}),
|
|
680
|
+
);
|
|
681
|
+
}
|
|
434
682
|
}
|
|
435
683
|
|
|
436
684
|
if (from || replyTo) {
|