@stamhoofd/backend 2.96.2 → 2.97.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 +10 -10
- package/src/audit-logs/EmailLogger.ts +27 -22
- package/src/endpoints/global/email/CreateEmailEndpoint.ts +8 -0
- package/src/endpoints/global/email/GetAdminEmailsEndpoint.test.ts +174 -0
- package/src/endpoints/global/email/GetEmailEndpoint.ts +2 -2
- package/src/endpoints/global/email/GetUserEmailsEndpoint.test.ts +623 -0
- package/src/endpoints/global/email/GetUserEmailsEndpoint.ts +2 -1
- package/src/endpoints/global/email/PatchEmailEndpoint.test.ts +672 -3
- package/src/endpoints/global/email/PatchEmailEndpoint.ts +28 -33
- package/src/endpoints/global/email-recipients/GetEmailRecipientsEndpoint.test.ts +167 -0
- package/src/endpoints/global/email-recipients/GetEmailRecipientsEndpoint.ts +1 -1
- package/src/endpoints/global/email-recipients/helpers/validateEmailRecipientFilter.ts +21 -4
- package/src/endpoints/global/registration/GetRegistrationsEndpoint.ts +36 -19
- package/src/helpers/AdminPermissionChecker.ts +8 -3
- package/src/helpers/AuthenticatedStructures.ts +42 -14
- package/src/helpers/Context.ts +3 -3
- package/src/seeds/1755790070-fill-email-recipient-errors.ts +3 -3
- package/src/sql-filters/members.ts +2 -2
- package/src/sql-filters/registrations.ts +1 -1
|
@@ -1,10 +1,19 @@
|
|
|
1
|
+
/* eslint-disable jest/no-conditional-expect */
|
|
2
|
+
import { AutoEncoderPatchType } from '@simonbackx/simple-encoding';
|
|
1
3
|
import { Request } from '@simonbackx/simple-endpoints';
|
|
2
|
-
import { Email, Organization, OrganizationFactory, RegistrationPeriod, RegistrationPeriodFactory, Token, User, UserFactory } from '@stamhoofd/models';
|
|
3
|
-
import { AccessRight, EmailStatus, Email as EmailStruct, OrganizationEmail, PermissionLevel, Permissions, PermissionsResourceType, ResourcePermissions, Version } from '@stamhoofd/structures';
|
|
4
|
+
import { Email, EmailRecipient, GroupFactory, MemberFactory, Organization, OrganizationFactory, RegistrationFactory, RegistrationPeriod, RegistrationPeriodFactory, Token, User, UserFactory } from '@stamhoofd/models';
|
|
5
|
+
import { AccessRight, EmailRecipientFilter, EmailRecipientFilterType, EmailRecipientSubfilter, EmailStatus, Email as EmailStruct, OrganizationEmail, Parent, PermissionLevel, Permissions, PermissionsResourceType, ResourcePermissions, UserPermissions, Version } from '@stamhoofd/structures';
|
|
4
6
|
import { STExpect, TestUtils } from '@stamhoofd/test-utils';
|
|
5
7
|
import { testServer } from '../../../../tests/helpers/TestServer';
|
|
6
8
|
import { PatchEmailEndpoint } from './PatchEmailEndpoint';
|
|
7
|
-
|
|
9
|
+
|
|
10
|
+
// Import recipient loaders to initialize them
|
|
11
|
+
import { Formatter } from '@stamhoofd/utility';
|
|
12
|
+
import '../../../email-recipient-loaders/members';
|
|
13
|
+
import '../../../email-recipient-loaders/orders';
|
|
14
|
+
import '../../../email-recipient-loaders/receivable-balances';
|
|
15
|
+
import '../../../email-recipient-loaders/registrations';
|
|
16
|
+
import { EmailMocker } from '@stamhoofd/email';
|
|
8
17
|
|
|
9
18
|
const baseUrl = `/v${Version}/email`;
|
|
10
19
|
|
|
@@ -32,6 +41,8 @@ describe('Endpoint.PatchEmailEndpoint', () => {
|
|
|
32
41
|
});
|
|
33
42
|
|
|
34
43
|
beforeAll(async () => {
|
|
44
|
+
TestUtils.setEnvironment('userMode', 'platform');
|
|
45
|
+
|
|
35
46
|
period = await new RegistrationPeriodFactory({
|
|
36
47
|
startDate: new Date(2023, 0, 1),
|
|
37
48
|
endDate: new Date(2023, 11, 31),
|
|
@@ -532,4 +543,662 @@ describe('Endpoint.PatchEmailEndpoint', () => {
|
|
|
532
543
|
|
|
533
544
|
await expect(patchEmail(body, token, organization)).toResolve();
|
|
534
545
|
});
|
|
546
|
+
|
|
547
|
+
test('Should send email to members list with correct replacements and recipients', async () => {
|
|
548
|
+
// Create test groups
|
|
549
|
+
const testGroup = await new GroupFactory({
|
|
550
|
+
organization,
|
|
551
|
+
}).create();
|
|
552
|
+
|
|
553
|
+
// Update the user to have group permissions
|
|
554
|
+
if (!user.permissions) {
|
|
555
|
+
user.permissions = UserPermissions.create({});
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Get existing organization permissions
|
|
559
|
+
// Create new organization permissions if they don't exist
|
|
560
|
+
user.permissions.organizationPermissions.set(organization.id, Permissions.create({
|
|
561
|
+
level: PermissionLevel.None,
|
|
562
|
+
resources: new Map([
|
|
563
|
+
[PermissionsResourceType.Senders, new Map([[sender.id, ResourcePermissions.create({
|
|
564
|
+
resourceName: sender.name!,
|
|
565
|
+
level: PermissionLevel.None,
|
|
566
|
+
accessRights: [AccessRight.SendMessages],
|
|
567
|
+
})]])],
|
|
568
|
+
[PermissionsResourceType.Groups, new Map([[testGroup.id, ResourcePermissions.create({
|
|
569
|
+
resourceName: testGroup.settings.name.toString(),
|
|
570
|
+
level: PermissionLevel.Read, // Need at least Read access to access group members through registrations
|
|
571
|
+
accessRights: [],
|
|
572
|
+
})]])],
|
|
573
|
+
]),
|
|
574
|
+
}));
|
|
575
|
+
await user.save();
|
|
576
|
+
|
|
577
|
+
// Create test users - one with email and one without
|
|
578
|
+
const userWithEmail = await new UserFactory({
|
|
579
|
+
organization,
|
|
580
|
+
email: 'member-with-email@test.com',
|
|
581
|
+
}).create();
|
|
582
|
+
|
|
583
|
+
const userWithoutEmail = await new UserFactory({
|
|
584
|
+
organization,
|
|
585
|
+
email: 'user-without-email@test.com', // User has email but member won't
|
|
586
|
+
}).create();
|
|
587
|
+
|
|
588
|
+
// Create members - one with email address and one without
|
|
589
|
+
const memberWithEmail = await new MemberFactory({
|
|
590
|
+
organization,
|
|
591
|
+
user: userWithEmail,
|
|
592
|
+
}).create();
|
|
593
|
+
memberWithEmail.details.email = 'member-with-email@test.com';
|
|
594
|
+
await memberWithEmail.save();
|
|
595
|
+
|
|
596
|
+
const memberWithoutEmail = await new MemberFactory({
|
|
597
|
+
organization,
|
|
598
|
+
user: userWithoutEmail,
|
|
599
|
+
}).create();
|
|
600
|
+
// Explicitly ensure this member has no email
|
|
601
|
+
memberWithoutEmail.details.email = null;
|
|
602
|
+
await memberWithoutEmail.save();
|
|
603
|
+
|
|
604
|
+
await new RegistrationFactory({
|
|
605
|
+
member: memberWithEmail,
|
|
606
|
+
group: testGroup,
|
|
607
|
+
}).create();
|
|
608
|
+
|
|
609
|
+
await new RegistrationFactory({
|
|
610
|
+
member: memberWithoutEmail,
|
|
611
|
+
group: testGroup,
|
|
612
|
+
}).create();
|
|
613
|
+
|
|
614
|
+
// No mocking needed - we'll use the real recipient loading logic
|
|
615
|
+
|
|
616
|
+
// Create an email with recipient filter targeting these specific members
|
|
617
|
+
const email = new Email();
|
|
618
|
+
email.subject = 'Test Email with Replacements {{firstName}}';
|
|
619
|
+
email.status = EmailStatus.Draft;
|
|
620
|
+
email.text = 'Hello {{firstName}} {{lastName}}, this is a test email. {{unsubscribeUrl}} {{loginDetails}}';
|
|
621
|
+
email.html = `<!DOCTYPE html>
|
|
622
|
+
<html>
|
|
623
|
+
<head>
|
|
624
|
+
<meta charset="utf-8" />
|
|
625
|
+
<title>Test Email</title>
|
|
626
|
+
</head>
|
|
627
|
+
<body>
|
|
628
|
+
<p>{{greeting}}</p>
|
|
629
|
+
<p>Hello {{firstName}} {{lastName}}, this is a test email.</p>
|
|
630
|
+
<p>Login details: {{loginDetails}}</p>
|
|
631
|
+
<p>Unsubscribe: <a href="{{unsubscribeUrl}}">Click here</a></p>
|
|
632
|
+
<p>Member firstname: {{firstNameMember}}</p>
|
|
633
|
+
<p>Balance table: {{balanceTable}}</p>
|
|
634
|
+
<p>Outstanding balance: {{outstandingBalance}}</p>
|
|
635
|
+
</body>
|
|
636
|
+
</html>`;
|
|
637
|
+
email.json = {};
|
|
638
|
+
|
|
639
|
+
email.userId = user.id;
|
|
640
|
+
email.organizationId = organization.id;
|
|
641
|
+
email.senderId = sender.id;
|
|
642
|
+
|
|
643
|
+
// Set up recipient filter to target our specific members
|
|
644
|
+
email.recipientFilter = EmailRecipientFilter.create({
|
|
645
|
+
filters: [
|
|
646
|
+
EmailRecipientSubfilter.create({
|
|
647
|
+
type: EmailRecipientFilterType.Members,
|
|
648
|
+
filter: {
|
|
649
|
+
id: { $in: [memberWithEmail.id, memberWithoutEmail.id] },
|
|
650
|
+
},
|
|
651
|
+
}),
|
|
652
|
+
],
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
await email.save();
|
|
656
|
+
|
|
657
|
+
// First try to build recipients with proper context by calling through the endpoint
|
|
658
|
+
const body = EmailStruct.patch({
|
|
659
|
+
id: email.id,
|
|
660
|
+
status: EmailStatus.Sending,
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
// This should start the recipient building process in the background
|
|
664
|
+
const response = await patchEmail(body, token, organization);
|
|
665
|
+
expect(response.status).toBe(200);
|
|
666
|
+
|
|
667
|
+
// Wait for the background process to complete (or fail)
|
|
668
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
669
|
+
|
|
670
|
+
// Refresh the email to check the status
|
|
671
|
+
await email.refresh();
|
|
672
|
+
|
|
673
|
+
// Check that EmailRecipient records were created correctly
|
|
674
|
+
const recipients = await EmailRecipient.select()
|
|
675
|
+
.where('emailId', email.id)
|
|
676
|
+
.fetch();
|
|
677
|
+
|
|
678
|
+
expect(recipients).toHaveLength(3);
|
|
679
|
+
|
|
680
|
+
// Two recipients with null email for both members
|
|
681
|
+
const recipientsWithoutEmail = recipients.filter(r => r.email === null);
|
|
682
|
+
expect(recipientsWithoutEmail).toHaveLength(2);
|
|
683
|
+
|
|
684
|
+
// Check one extra recipient with the email = memberWithEmail's email
|
|
685
|
+
const recipientWithEmail = recipients.find(r => r.email === userWithEmail.email);
|
|
686
|
+
expect(recipientWithEmail).toBeDefined();
|
|
687
|
+
expect(recipientWithEmail?.memberId).toBe(memberWithEmail.id);
|
|
688
|
+
|
|
689
|
+
for (const recipient of recipients) {
|
|
690
|
+
// Check {{greeting}} replacement
|
|
691
|
+
const greeting = recipient.replacements.find(r => r.token === 'greeting');
|
|
692
|
+
expect(greeting).toBeDefined();
|
|
693
|
+
expect(greeting?.value).toEqual('Dag ' + recipient.firstName + ',');
|
|
694
|
+
|
|
695
|
+
// Check loginDetails replacement includes email address
|
|
696
|
+
const loginDetails = recipient.replacements.find(r => r.token === 'loginDetails');
|
|
697
|
+
expect(loginDetails).toBeDefined();
|
|
698
|
+
|
|
699
|
+
if (recipient.email) {
|
|
700
|
+
expect(loginDetails?.html).toContain(recipient.email || ''); // If no email, won't contain it
|
|
701
|
+
}
|
|
702
|
+
else {
|
|
703
|
+
// Cehck loginDetails is an empty string
|
|
704
|
+
expect(loginDetails?.html).toBe(undefined);
|
|
705
|
+
expect(loginDetails?.value).toBe('');
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const balanceTable = recipient.replacements.find(r => r.token === 'balanceTable');
|
|
709
|
+
expect(balanceTable).toBeDefined();
|
|
710
|
+
expect(balanceTable?.html).toInclude($t('4c4f6571-f7b5-469d-a16f-b1547b43a610'));
|
|
711
|
+
|
|
712
|
+
// Outstanding balance
|
|
713
|
+
const outstandingBalance = recipient.replacements.find(r => r.token === 'outstandingBalance');
|
|
714
|
+
expect(outstandingBalance).toBeDefined();
|
|
715
|
+
expect(outstandingBalance?.value).toBe(Formatter.price(0));
|
|
716
|
+
|
|
717
|
+
// firstNameMember
|
|
718
|
+
const firstNameMember = recipient.replacements.find(r => r.token === 'firstNameMember');
|
|
719
|
+
expect(firstNameMember).toBeDefined();
|
|
720
|
+
if (recipient.memberId === memberWithEmail.id) {
|
|
721
|
+
expect(firstNameMember?.value).toBe(memberWithEmail.details.firstName);
|
|
722
|
+
}
|
|
723
|
+
else if (recipient.memberId === memberWithoutEmail.id) {
|
|
724
|
+
expect(firstNameMember?.value).toBe(memberWithoutEmail.details.firstName);
|
|
725
|
+
}
|
|
726
|
+
else {
|
|
727
|
+
throw new Error('Recipient has unexpected memberId ' + recipient.memberId);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// Check lastNameMember is not present, because it is not used in the html
|
|
731
|
+
const lastNameMember = recipient.replacements.find(r => r.token === 'lastNameMember');
|
|
732
|
+
expect(lastNameMember).toBeUndefined();
|
|
733
|
+
}
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
test('Should merge identical emails', async () => {
|
|
737
|
+
// When you have two members with the same email address
|
|
738
|
+
// test that we only send a single email to this email address as long as we don't include replacements
|
|
739
|
+
// that are different between the members (like firstNameMember)
|
|
740
|
+
|
|
741
|
+
// Create test groups
|
|
742
|
+
const testGroup = await new GroupFactory({
|
|
743
|
+
organization,
|
|
744
|
+
}).create();
|
|
745
|
+
|
|
746
|
+
// Update the user to have group permissions
|
|
747
|
+
if (!user.permissions) {
|
|
748
|
+
user.permissions = UserPermissions.create({});
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// Get existing organization permissions
|
|
752
|
+
// Create new organization permissions if they don't exist
|
|
753
|
+
user.permissions.organizationPermissions.set(organization.id, Permissions.create({
|
|
754
|
+
level: PermissionLevel.None,
|
|
755
|
+
resources: new Map([
|
|
756
|
+
[PermissionsResourceType.Senders, new Map([[sender.id, ResourcePermissions.create({
|
|
757
|
+
resourceName: sender.name!,
|
|
758
|
+
level: PermissionLevel.None,
|
|
759
|
+
accessRights: [AccessRight.SendMessages],
|
|
760
|
+
})]])],
|
|
761
|
+
[PermissionsResourceType.Groups, new Map([[testGroup.id, ResourcePermissions.create({
|
|
762
|
+
resourceName: testGroup.settings.name.toString(),
|
|
763
|
+
level: PermissionLevel.Read, // Need at least Read access to access group members through registrations
|
|
764
|
+
accessRights: [],
|
|
765
|
+
})]])],
|
|
766
|
+
]),
|
|
767
|
+
}));
|
|
768
|
+
await user.save();
|
|
769
|
+
|
|
770
|
+
const member1 = await new MemberFactory({
|
|
771
|
+
organization,
|
|
772
|
+
}).create();
|
|
773
|
+
member1.details.parents = [Parent.create({
|
|
774
|
+
firstName: 'Identical',
|
|
775
|
+
lastName: 'Email',
|
|
776
|
+
email: 'identical-email@test.com',
|
|
777
|
+
})];
|
|
778
|
+
|
|
779
|
+
await member1.save();
|
|
780
|
+
|
|
781
|
+
const member2 = await new MemberFactory({
|
|
782
|
+
organization,
|
|
783
|
+
}).create();
|
|
784
|
+
member2.details.parents = [Parent.create({
|
|
785
|
+
firstName: 'Identical',
|
|
786
|
+
lastName: 'Email',
|
|
787
|
+
email: 'identical-email@test.com',
|
|
788
|
+
})];
|
|
789
|
+
await member2.save();
|
|
790
|
+
|
|
791
|
+
await new RegistrationFactory({
|
|
792
|
+
member: member1,
|
|
793
|
+
group: testGroup,
|
|
794
|
+
}).create();
|
|
795
|
+
|
|
796
|
+
await new RegistrationFactory({
|
|
797
|
+
member: member2,
|
|
798
|
+
group: testGroup,
|
|
799
|
+
}).create();
|
|
800
|
+
|
|
801
|
+
const email = new Email();
|
|
802
|
+
email.subject = 'Test Email with Replacements {{firstName}}';
|
|
803
|
+
email.status = EmailStatus.Draft;
|
|
804
|
+
email.text = 'Hello {{firstName}} {{lastName}}, this is a test email. {{unsubscribeUrl}} {{loginDetails}}';
|
|
805
|
+
email.html = `<!DOCTYPE html>
|
|
806
|
+
<html>
|
|
807
|
+
<head>
|
|
808
|
+
<meta charset="utf-8" />
|
|
809
|
+
<title>Test Email</title>
|
|
810
|
+
</head>
|
|
811
|
+
<body>
|
|
812
|
+
<p>Hello {{firstName}} {{lastName}}, this is a test email.</p>
|
|
813
|
+
<p>Login details: {{loginDetails}}</p>
|
|
814
|
+
<p>Unsubscribe: <a href="{{unsubscribeUrl}}">Click here</a></p>
|
|
815
|
+
</body>
|
|
816
|
+
</html>`;
|
|
817
|
+
email.json = {};
|
|
818
|
+
|
|
819
|
+
email.userId = user.id;
|
|
820
|
+
email.organizationId = organization.id;
|
|
821
|
+
email.senderId = sender.id;
|
|
822
|
+
|
|
823
|
+
// Set up recipient filter to target our specific members
|
|
824
|
+
email.recipientFilter = EmailRecipientFilter.create({
|
|
825
|
+
filters: [
|
|
826
|
+
EmailRecipientSubfilter.create({
|
|
827
|
+
type: EmailRecipientFilterType.MemberParents,
|
|
828
|
+
filter: {
|
|
829
|
+
id: { $in: [member1.id, member2.id] },
|
|
830
|
+
},
|
|
831
|
+
}),
|
|
832
|
+
],
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
await email.save();
|
|
836
|
+
|
|
837
|
+
// First try to build recipients with proper context by calling through the endpoint
|
|
838
|
+
const body = EmailStruct.patch({
|
|
839
|
+
id: email.id,
|
|
840
|
+
status: EmailStatus.Sending,
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
// This should start the recipient building process in the background
|
|
844
|
+
const response = await patchEmail(body, token, organization);
|
|
845
|
+
expect(response.status).toBe(200);
|
|
846
|
+
|
|
847
|
+
// Wait for the background process to complete (or fail)
|
|
848
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
849
|
+
|
|
850
|
+
// Refresh the email to check the status
|
|
851
|
+
await email.refresh();
|
|
852
|
+
|
|
853
|
+
expect(email.status).toBe(EmailStatus.Sent);
|
|
854
|
+
|
|
855
|
+
// Check that EmailRecipient records were created correctly
|
|
856
|
+
const recipients = await EmailRecipient.select()
|
|
857
|
+
.where('emailId', email.id)
|
|
858
|
+
.fetch();
|
|
859
|
+
|
|
860
|
+
expect(recipients).toHaveLength(2);
|
|
861
|
+
|
|
862
|
+
// No recipients with null, because we only targeted parents
|
|
863
|
+
const recipientsWithoutEmail = recipients.filter(r => r.email === null);
|
|
864
|
+
expect(recipientsWithoutEmail).toHaveLength(0);
|
|
865
|
+
|
|
866
|
+
const recipientsWithEmail = recipients.filter(r => r.email === 'identical-email@test.com');
|
|
867
|
+
expect(recipientsWithEmail).toHaveLength(2);
|
|
868
|
+
|
|
869
|
+
// Check sentAt, only one
|
|
870
|
+
const sentRecipients = recipients.filter(r => r.sentAt !== null);
|
|
871
|
+
expect(sentRecipients).toHaveLength(1);
|
|
872
|
+
|
|
873
|
+
// Check one with duplicateOfId set
|
|
874
|
+
const duplicateRecipients = recipientsWithEmail.filter(r => r.duplicateOfRecipientId !== null);
|
|
875
|
+
expect(duplicateRecipients).toHaveLength(1);
|
|
876
|
+
expect(duplicateRecipients[0].duplicateOfRecipientId).toBe(sentRecipients[0].id);
|
|
877
|
+
|
|
878
|
+
// Check only one email is sent
|
|
879
|
+
expect(await EmailMocker.getSucceededCount()).toBe(1);
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
test('[Regression] Should merge identical emails to 3 members', async () => {
|
|
883
|
+
// When you have 3 members with the same email address
|
|
884
|
+
// test that we only send a single email to this email address as long as we don't include replacements
|
|
885
|
+
// that are different between the members (like firstNameMember)
|
|
886
|
+
|
|
887
|
+
// Create test groups
|
|
888
|
+
const testGroup = await new GroupFactory({
|
|
889
|
+
organization,
|
|
890
|
+
}).create();
|
|
891
|
+
|
|
892
|
+
// Update the user to have group permissions
|
|
893
|
+
if (!user.permissions) {
|
|
894
|
+
user.permissions = UserPermissions.create({});
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// Get existing organization permissions
|
|
898
|
+
// Create new organization permissions if they don't exist
|
|
899
|
+
user.permissions.organizationPermissions.set(organization.id, Permissions.create({
|
|
900
|
+
level: PermissionLevel.None,
|
|
901
|
+
resources: new Map([
|
|
902
|
+
[PermissionsResourceType.Senders, new Map([[sender.id, ResourcePermissions.create({
|
|
903
|
+
resourceName: sender.name!,
|
|
904
|
+
level: PermissionLevel.None,
|
|
905
|
+
accessRights: [AccessRight.SendMessages],
|
|
906
|
+
})]])],
|
|
907
|
+
[PermissionsResourceType.Groups, new Map([[testGroup.id, ResourcePermissions.create({
|
|
908
|
+
resourceName: testGroup.settings.name.toString(),
|
|
909
|
+
level: PermissionLevel.Read, // Need at least Read access to access group members through registrations
|
|
910
|
+
accessRights: [],
|
|
911
|
+
})]])],
|
|
912
|
+
]),
|
|
913
|
+
}));
|
|
914
|
+
await user.save();
|
|
915
|
+
|
|
916
|
+
const member1 = await new MemberFactory({
|
|
917
|
+
organization,
|
|
918
|
+
}).create();
|
|
919
|
+
member1.details.parents = [Parent.create({
|
|
920
|
+
firstName: 'Identical',
|
|
921
|
+
lastName: 'Email',
|
|
922
|
+
email: 'identical-email@test.com',
|
|
923
|
+
})];
|
|
924
|
+
|
|
925
|
+
await member1.save();
|
|
926
|
+
|
|
927
|
+
const member2 = await new MemberFactory({
|
|
928
|
+
organization,
|
|
929
|
+
}).create();
|
|
930
|
+
member2.details.parents = [Parent.create({
|
|
931
|
+
firstName: 'Identical',
|
|
932
|
+
lastName: 'Email',
|
|
933
|
+
email: 'identical-email@test.com',
|
|
934
|
+
})];
|
|
935
|
+
await member2.save();
|
|
936
|
+
|
|
937
|
+
const member3 = await new MemberFactory({
|
|
938
|
+
organization,
|
|
939
|
+
}).create();
|
|
940
|
+
member3.details.parents = [Parent.create({
|
|
941
|
+
firstName: 'Identical',
|
|
942
|
+
lastName: 'Email',
|
|
943
|
+
email: 'identical-email@test.com',
|
|
944
|
+
})];
|
|
945
|
+
await member3.save();
|
|
946
|
+
|
|
947
|
+
await new RegistrationFactory({
|
|
948
|
+
member: member1,
|
|
949
|
+
group: testGroup,
|
|
950
|
+
}).create();
|
|
951
|
+
|
|
952
|
+
await new RegistrationFactory({
|
|
953
|
+
member: member2,
|
|
954
|
+
group: testGroup,
|
|
955
|
+
}).create();
|
|
956
|
+
|
|
957
|
+
await new RegistrationFactory({
|
|
958
|
+
member: member3,
|
|
959
|
+
group: testGroup,
|
|
960
|
+
}).create();
|
|
961
|
+
|
|
962
|
+
const email = new Email();
|
|
963
|
+
email.subject = 'Test Email with Replacements {{firstName}}';
|
|
964
|
+
email.status = EmailStatus.Draft;
|
|
965
|
+
email.text = 'Hello {{firstName}} {{lastName}}, this is a test email. {{unsubscribeUrl}} {{loginDetails}}';
|
|
966
|
+
email.html = `<!DOCTYPE html>
|
|
967
|
+
<html>
|
|
968
|
+
<head>
|
|
969
|
+
<meta charset="utf-8" />
|
|
970
|
+
<title>Test Email</title>
|
|
971
|
+
</head>
|
|
972
|
+
<body>
|
|
973
|
+
<p>Hello {{firstName}} {{lastName}}, this is a test email.</p>
|
|
974
|
+
<p>Login details: {{loginDetails}}</p>
|
|
975
|
+
<p>Unsubscribe: <a href="{{unsubscribeUrl}}">Click here</a></p>
|
|
976
|
+
</body>
|
|
977
|
+
</html>`;
|
|
978
|
+
email.json = {};
|
|
979
|
+
|
|
980
|
+
email.userId = user.id;
|
|
981
|
+
email.organizationId = organization.id;
|
|
982
|
+
email.senderId = sender.id;
|
|
983
|
+
|
|
984
|
+
// Set up recipient filter to target our specific members
|
|
985
|
+
email.recipientFilter = EmailRecipientFilter.create({
|
|
986
|
+
filters: [
|
|
987
|
+
EmailRecipientSubfilter.create({
|
|
988
|
+
type: EmailRecipientFilterType.MemberParents,
|
|
989
|
+
filter: {
|
|
990
|
+
id: { $in: [member1.id, member2.id, member3.id] },
|
|
991
|
+
},
|
|
992
|
+
}),
|
|
993
|
+
],
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
await email.save();
|
|
997
|
+
|
|
998
|
+
// First try to build recipients with proper context by calling through the endpoint
|
|
999
|
+
const body = EmailStruct.patch({
|
|
1000
|
+
id: email.id,
|
|
1001
|
+
status: EmailStatus.Sending,
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
// This should start the recipient building process in the background
|
|
1005
|
+
const response = await patchEmail(body, token, organization);
|
|
1006
|
+
expect(response.status).toBe(200);
|
|
1007
|
+
|
|
1008
|
+
// Wait for the background process to complete (or fail)
|
|
1009
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
1010
|
+
|
|
1011
|
+
// Refresh the email to check the status
|
|
1012
|
+
await email.refresh();
|
|
1013
|
+
|
|
1014
|
+
expect(email.status).toBe(EmailStatus.Sent);
|
|
1015
|
+
|
|
1016
|
+
// Check that EmailRecipient records were created correctly
|
|
1017
|
+
const recipients = await EmailRecipient.select()
|
|
1018
|
+
.where('emailId', email.id)
|
|
1019
|
+
.fetch();
|
|
1020
|
+
|
|
1021
|
+
expect(recipients).toHaveLength(3);
|
|
1022
|
+
|
|
1023
|
+
// No recipients with null, because we only targeted parents
|
|
1024
|
+
const recipientsWithoutEmail = recipients.filter(r => r.email === null);
|
|
1025
|
+
expect(recipientsWithoutEmail).toHaveLength(0);
|
|
1026
|
+
|
|
1027
|
+
const recipientsWithEmail = recipients.filter(r => r.email === 'identical-email@test.com');
|
|
1028
|
+
expect(recipientsWithEmail).toHaveLength(3);
|
|
1029
|
+
|
|
1030
|
+
// Check sentAt, only one
|
|
1031
|
+
const sentRecipients = recipients.filter(r => r.sentAt !== null);
|
|
1032
|
+
expect(sentRecipients).toHaveLength(1);
|
|
1033
|
+
|
|
1034
|
+
// Check one with duplicateOfId set
|
|
1035
|
+
const duplicateRecipients = recipientsWithEmail.filter(r => r.duplicateOfRecipientId !== null);
|
|
1036
|
+
expect(duplicateRecipients).toHaveLength(2);
|
|
1037
|
+
expect(duplicateRecipients[0].duplicateOfRecipientId).toBe(sentRecipients[0].id);
|
|
1038
|
+
expect(duplicateRecipients[1].duplicateOfRecipientId).toBe(sentRecipients[0].id);
|
|
1039
|
+
|
|
1040
|
+
// Check only one email is sent
|
|
1041
|
+
expect(await EmailMocker.getSucceededCount()).toBe(1);
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
test('Should not merge emails to same email with different replacements', async () => {
|
|
1045
|
+
// When you have two members with the same email address
|
|
1046
|
+
// test that we only send a single email to this email address as long as we don't include replacements
|
|
1047
|
+
// that are different between the members (like firstNameMember)
|
|
1048
|
+
|
|
1049
|
+
// Create test groups
|
|
1050
|
+
const testGroup = await new GroupFactory({
|
|
1051
|
+
organization,
|
|
1052
|
+
}).create();
|
|
1053
|
+
|
|
1054
|
+
// Update the user to have group permissions
|
|
1055
|
+
if (!user.permissions) {
|
|
1056
|
+
user.permissions = UserPermissions.create({});
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// Get existing organization permissions
|
|
1060
|
+
// Create new organization permissions if they don't exist
|
|
1061
|
+
user.permissions.organizationPermissions.set(organization.id, Permissions.create({
|
|
1062
|
+
level: PermissionLevel.None,
|
|
1063
|
+
resources: new Map([
|
|
1064
|
+
[PermissionsResourceType.Senders, new Map([[sender.id, ResourcePermissions.create({
|
|
1065
|
+
resourceName: sender.name!,
|
|
1066
|
+
level: PermissionLevel.None,
|
|
1067
|
+
accessRights: [AccessRight.SendMessages],
|
|
1068
|
+
})]])],
|
|
1069
|
+
[PermissionsResourceType.Groups, new Map([[testGroup.id, ResourcePermissions.create({
|
|
1070
|
+
resourceName: testGroup.settings.name.toString(),
|
|
1071
|
+
level: PermissionLevel.Read, // Need at least Read access to access group members through registrations
|
|
1072
|
+
accessRights: [],
|
|
1073
|
+
})]])],
|
|
1074
|
+
]),
|
|
1075
|
+
}));
|
|
1076
|
+
await user.save();
|
|
1077
|
+
|
|
1078
|
+
const member1 = await new MemberFactory({
|
|
1079
|
+
organization,
|
|
1080
|
+
}).create();
|
|
1081
|
+
member1.details.parents = [Parent.create({
|
|
1082
|
+
firstName: 'Identical',
|
|
1083
|
+
lastName: 'Email',
|
|
1084
|
+
email: 'identical-email@test.com',
|
|
1085
|
+
})];
|
|
1086
|
+
|
|
1087
|
+
await member1.save();
|
|
1088
|
+
|
|
1089
|
+
const member2 = await new MemberFactory({
|
|
1090
|
+
organization,
|
|
1091
|
+
}).create();
|
|
1092
|
+
member2.details.parents = [Parent.create({
|
|
1093
|
+
firstName: 'Identical',
|
|
1094
|
+
lastName: 'Email',
|
|
1095
|
+
email: 'identical-email@test.com',
|
|
1096
|
+
})];
|
|
1097
|
+
await member2.save();
|
|
1098
|
+
|
|
1099
|
+
await new RegistrationFactory({
|
|
1100
|
+
member: member1,
|
|
1101
|
+
group: testGroup,
|
|
1102
|
+
}).create();
|
|
1103
|
+
|
|
1104
|
+
await new RegistrationFactory({
|
|
1105
|
+
member: member2,
|
|
1106
|
+
group: testGroup,
|
|
1107
|
+
}).create();
|
|
1108
|
+
|
|
1109
|
+
const email = new Email();
|
|
1110
|
+
email.subject = 'Test Email with Replacements {{firstName}}';
|
|
1111
|
+
email.status = EmailStatus.Draft;
|
|
1112
|
+
email.text = 'Hello {{firstName}} {{lastName}}, this is a test email. {{unsubscribeUrl}} {{loginDetails}}';
|
|
1113
|
+
email.html = `<!DOCTYPE html>
|
|
1114
|
+
<html>
|
|
1115
|
+
<head>
|
|
1116
|
+
<meta charset="utf-8" />
|
|
1117
|
+
<title>Test Email</title>
|
|
1118
|
+
</head>
|
|
1119
|
+
<body>
|
|
1120
|
+
<p>Hello {{firstName}} {{lastName}}, this is a test email.</p>
|
|
1121
|
+
<p>Login details: {{loginDetails}}</p>
|
|
1122
|
+
<p>Unsubscribe: <a href="{{unsubscribeUrl}}">Click here</a></p>
|
|
1123
|
+
<p>Member firstname: {{firstNameMember}}</p>
|
|
1124
|
+
</body>
|
|
1125
|
+
</html>`;
|
|
1126
|
+
email.json = {};
|
|
1127
|
+
|
|
1128
|
+
email.userId = user.id;
|
|
1129
|
+
email.organizationId = organization.id;
|
|
1130
|
+
email.senderId = sender.id;
|
|
1131
|
+
|
|
1132
|
+
// Set up recipient filter to target our specific members
|
|
1133
|
+
email.recipientFilter = EmailRecipientFilter.create({
|
|
1134
|
+
filters: [
|
|
1135
|
+
EmailRecipientSubfilter.create({
|
|
1136
|
+
type: EmailRecipientFilterType.MemberParents,
|
|
1137
|
+
filter: {
|
|
1138
|
+
id: { $in: [member1.id, member2.id] },
|
|
1139
|
+
},
|
|
1140
|
+
}),
|
|
1141
|
+
],
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
await email.save();
|
|
1145
|
+
|
|
1146
|
+
// First try to build recipients with proper context by calling through the endpoint
|
|
1147
|
+
const body = EmailStruct.patch({
|
|
1148
|
+
id: email.id,
|
|
1149
|
+
status: EmailStatus.Sending,
|
|
1150
|
+
});
|
|
1151
|
+
|
|
1152
|
+
// This should start the recipient building process in the background
|
|
1153
|
+
const response = await patchEmail(body, token, organization);
|
|
1154
|
+
expect(response.status).toBe(200);
|
|
1155
|
+
|
|
1156
|
+
// Wait for the background process to complete (or fail)
|
|
1157
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
1158
|
+
|
|
1159
|
+
// Refresh the email to check the status
|
|
1160
|
+
await email.refresh();
|
|
1161
|
+
|
|
1162
|
+
expect(email.status).toBe(EmailStatus.Sent);
|
|
1163
|
+
|
|
1164
|
+
// Check that EmailRecipient records were created correctly
|
|
1165
|
+
const recipients = await EmailRecipient.select()
|
|
1166
|
+
.where('emailId', email.id)
|
|
1167
|
+
.fetch();
|
|
1168
|
+
|
|
1169
|
+
expect(recipients).toHaveLength(2);
|
|
1170
|
+
|
|
1171
|
+
// No recipients with null, because we only targeted parents
|
|
1172
|
+
const recipientsWithoutEmail = recipients.filter(r => r.email === null);
|
|
1173
|
+
expect(recipientsWithoutEmail).toHaveLength(0);
|
|
1174
|
+
|
|
1175
|
+
const recipientsWithEmail = recipients.filter(r => r.email === 'identical-email@test.com');
|
|
1176
|
+
expect(recipientsWithEmail).toHaveLength(2);
|
|
1177
|
+
|
|
1178
|
+
// Check sentAt, only one
|
|
1179
|
+
const sentRecipients = recipients.filter(r => r.sentAt !== null);
|
|
1180
|
+
expect(sentRecipients).toHaveLength(2);
|
|
1181
|
+
|
|
1182
|
+
// Check one with duplicateOfId set
|
|
1183
|
+
const duplicateRecipients = recipientsWithEmail.filter(r => r.duplicateOfRecipientId !== null);
|
|
1184
|
+
expect(duplicateRecipients).toHaveLength(0);
|
|
1185
|
+
|
|
1186
|
+
// Check only one email is sent
|
|
1187
|
+
expect(await EmailMocker.getSucceededCount()).toBe(2);
|
|
1188
|
+
|
|
1189
|
+
// Check content of each email is correct for each member. One should contain member1.firstName, other member2.firstName
|
|
1190
|
+
const sentEmails = await EmailMocker.getSucceededEmails();
|
|
1191
|
+
expect(sentEmails).toHaveLength(2);
|
|
1192
|
+
const emailWithMember1FirstName = sentEmails.find(e => e.html?.includes(member1.details.firstName!));
|
|
1193
|
+
const emailWithMember2FirstName = sentEmails.find(e => e.html?.includes(member2.details.firstName!));
|
|
1194
|
+
expect(emailWithMember1FirstName).toBeDefined();
|
|
1195
|
+
expect(emailWithMember2FirstName).toBeDefined();
|
|
1196
|
+
|
|
1197
|
+
expect(emailWithMember1FirstName).not.toBe(emailWithMember2FirstName);
|
|
1198
|
+
|
|
1199
|
+
// Check all emails contain Identical Email (name of the parent) in the body
|
|
1200
|
+
for (const sentEmail of sentEmails) {
|
|
1201
|
+
expect(sentEmail.html).toContain('Identical Email');
|
|
1202
|
+
}
|
|
1203
|
+
});
|
|
535
1204
|
});
|