@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.
Files changed (54) hide show
  1. package/dist/src/factories/RegistrationPeriodFactory.d.ts +1 -0
  2. package/dist/src/factories/RegistrationPeriodFactory.d.ts.map +1 -1
  3. package/dist/src/factories/RegistrationPeriodFactory.js +4 -0
  4. package/dist/src/factories/RegistrationPeriodFactory.js.map +1 -1
  5. package/dist/src/helpers/EmailBuilder.d.ts +27 -1
  6. package/dist/src/helpers/EmailBuilder.d.ts.map +1 -1
  7. package/dist/src/helpers/EmailBuilder.js +270 -25
  8. package/dist/src/helpers/EmailBuilder.js.map +1 -1
  9. package/dist/src/migrations/1756115317-email-deleted-at.sql +2 -0
  10. package/dist/src/migrations/1756293494-registration-period-next-period-id.sql +3 -0
  11. package/dist/src/migrations/1756293495-platform-next-period-id.sql +3 -0
  12. package/dist/src/migrations/1756387016-registration-period-custom-name.sql +2 -0
  13. package/dist/src/migrations/1756391212-email-recipients-duplicate.sql +3 -0
  14. package/dist/src/models/Email.d.ts +4 -2
  15. package/dist/src/models/Email.d.ts.map +1 -1
  16. package/dist/src/models/Email.js +160 -36
  17. package/dist/src/models/Email.js.map +1 -1
  18. package/dist/src/models/EmailRecipient.d.ts +7 -1
  19. package/dist/src/models/EmailRecipient.d.ts.map +1 -1
  20. package/dist/src/models/EmailRecipient.js +28 -2
  21. package/dist/src/models/EmailRecipient.js.map +1 -1
  22. package/dist/src/models/Event.d.ts.map +1 -1
  23. package/dist/src/models/Event.js +2 -0
  24. package/dist/src/models/Event.js.map +1 -1
  25. package/dist/src/models/Member.d.ts +1 -1
  26. package/dist/src/models/Member.d.ts.map +1 -1
  27. package/dist/src/models/Member.js +3 -3
  28. package/dist/src/models/Member.js.map +1 -1
  29. package/dist/src/models/Platform.d.ts +1 -0
  30. package/dist/src/models/Platform.d.ts.map +1 -1
  31. package/dist/src/models/Platform.js +5 -0
  32. package/dist/src/models/Platform.js.map +1 -1
  33. package/dist/src/models/RegistrationPeriod.d.ts +4 -1
  34. package/dist/src/models/RegistrationPeriod.d.ts.map +1 -1
  35. package/dist/src/models/RegistrationPeriod.js +32 -7
  36. package/dist/src/models/RegistrationPeriod.js.map +1 -1
  37. package/dist/src/models/User.d.ts.map +1 -1
  38. package/dist/src/models/User.js +5 -1
  39. package/dist/src/models/User.js.map +1 -1
  40. package/package.json +2 -2
  41. package/src/factories/RegistrationPeriodFactory.ts +4 -0
  42. package/src/helpers/EmailBuilder.ts +282 -34
  43. package/src/migrations/1756115317-email-deleted-at.sql +2 -0
  44. package/src/migrations/1756293494-registration-period-next-period-id.sql +3 -0
  45. package/src/migrations/1756293495-platform-next-period-id.sql +3 -0
  46. package/src/migrations/1756387016-registration-period-custom-name.sql +2 -0
  47. package/src/migrations/1756391212-email-recipients-duplicate.sql +3 -0
  48. package/src/models/Email.ts +184 -43
  49. package/src/models/EmailRecipient.ts +32 -3
  50. package/src/models/Event.ts +2 -0
  51. package/src/models/Member.ts +3 -3
  52. package/src/models/Platform.ts +4 -0
  53. package/src/models/RegistrationPeriod.ts +32 -7
  54. 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
- // Default signInUrl
395
- let signInUrl = 'https://' + (organization && STAMHOOFD.userMode === 'organization' ? organization.getHost() : STAMHOOFD.domains.dashboard) + '/login?email=' + encodeURIComponent(recipient.email);
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
- const recipientUser = await User.getForAuthentication(organization?.id ?? null, recipient.email, { allowWithoutAccount: true });
398
- if (!recipientUser || !recipientUser.hasAccount()) {
399
- // We can create a special token
400
- signInUrl = 'https://' + (organization && STAMHOOFD.userMode === 'organization' ? organization.getHost() : STAMHOOFD.domains.dashboard) + '/account-aanmaken?email=' + encodeURIComponent(recipient.email);
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
- recipient.replacements.push(Replacement.create({
404
- token: 'signInUrl',
405
- value: signInUrl,
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
- recipient.replacements.push(
409
- Replacement.create({
410
- token: 'loginDetails',
411
- value: '',
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 (organization && recipientUser && !recipient.replacements.find(r => r.token === 'balanceTable')) {
419
- const balanceItemModels = await CachedBalance.balanceForObjects(organization.id, [recipientUser.id], ReceivableBalanceType.user, true);
420
- const balanceItems = balanceItemModels.map(i => i.getStructure());
421
-
422
- // Get members
423
- recipient.replacements.push(
424
- Replacement.create({
425
- token: 'outstandingBalance',
426
- value: Formatter.price(balanceItems.reduce((sum, i) => sum + i.priceOpen, 0)),
427
- }),
428
- Replacement.create({
429
- token: 'balanceTable',
430
- value: '',
431
- html: BalanceItemStruct.getDetailsHTMLTable(balanceItems),
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) {
@@ -0,0 +1,2 @@
1
+ ALTER TABLE `emails`
2
+ ADD COLUMN `deletedAt` datetime NULL AFTER `updatedAt`;
@@ -0,0 +1,3 @@
1
+ ALTER TABLE `registration_periods`
2
+ ADD COLUMN `nextPeriodId` varchar(36) NULL AFTER `previousPeriodId`,
3
+ ADD FOREIGN KEY (`nextPeriodId`) REFERENCES `registration_periods` (`id`) ON UPDATE CASCADE ON DELETE SET NULL;
@@ -0,0 +1,3 @@
1
+ ALTER TABLE `platform`
2
+ ADD COLUMN `nextPeriodId` varchar(36) NULL AFTER `previousPeriodId`,
3
+ ADD FOREIGN KEY (`nextPeriodId`) REFERENCES `registration_periods` (`id`) ON UPDATE CASCADE ON DELETE SET NULL;
@@ -0,0 +1,2 @@
1
+ ALTER TABLE `registration_periods`
2
+ ADD COLUMN `customName` varchar(200) NULL AFTER `nextPeriodId`;
@@ -0,0 +1,3 @@
1
+ ALTER TABLE `email_recipients`
2
+ ADD COLUMN `duplicateOfRecipientId` varchar(36) NULL,
3
+ ADD FOREIGN KEY (`duplicateOfRecipientId`) REFERENCES `email_recipients` (`id`) ON UPDATE CASCADE ON DELETE SET NULL;