Added events for subscribe and unsubscribe

This commit is contained in:
Dries Augustyns
2025-12-02 09:56:27 +01:00
parent 2e6b485532
commit 2ef463ce5e
3 changed files with 92 additions and 8 deletions
+51 -5
View File
@@ -128,7 +128,7 @@ export class ContactService {
data: {email?: string; data?: Prisma.JsonValue; subscribed?: boolean},
): Promise<Contact> {
// First verify contact exists and belongs to project
await this.get(projectId, contactId);
const existing = await this.get(projectId, contactId);
const updateData: Prisma.ContactUpdateInput = {};
@@ -142,11 +142,28 @@ export class ContactService {
updateData.subscribed = data.subscribed;
}
// Track subscription status change
const isSubscriptionChanging =
data.subscribed !== undefined && existing.subscribed !== data.subscribed;
const wasSubscribed = existing.subscribed;
try {
return await prisma.contact.update({
const updated = await prisma.contact.update({
where: {id: contactId},
data: updateData,
});
// Track subscription event if status changed
if (isSubscriptionChanging) {
const {EventService} = await import('./EventService.js');
if (data.subscribed && !wasSubscribed) {
await EventService.trackEvent(projectId, 'contact.subscribed', contactId);
} else if (!data.subscribed && wasSubscribed) {
await EventService.trackEvent(projectId, 'contact.unsubscribed', contactId);
}
}
return updated;
} catch (error) {
// Check if this is a unique constraint violation (P2002)
if (error instanceof Error && 'code' in error && error.code === 'P2002') {
@@ -231,13 +248,30 @@ export class ContactService {
}
if (existing) {
return prisma.contact.update({
// Track subscription status change
const isSubscriptionChanging =
subscribed !== undefined && existing.subscribed !== subscribed;
const wasSubscribed = existing.subscribed;
const updated = await prisma.contact.update({
where: {id: existing.id},
data: {
data: Object.keys(mergedData).length > 0 ? (mergedData as Prisma.InputJsonValue) : Prisma.JsonNull,
...(subscribed !== undefined ? {subscribed} : {}),
},
});
// Track subscription event if status changed
if (isSubscriptionChanging) {
const {EventService} = await import('./EventService.js');
if (subscribed && !wasSubscribed) {
await EventService.trackEvent(projectId, 'contact.subscribed', updated.id);
} else if (!subscribed && wasSubscribed) {
await EventService.trackEvent(projectId, 'contact.unsubscribed', updated.id);
}
}
return updated;
} else {
return prisma.contact.create({
data: {
@@ -311,20 +345,32 @@ export class ContactService {
* PUBLIC: Subscribe a contact
*/
public static async subscribe(contactId: string): Promise<Contact> {
return prisma.contact.update({
const contact = await prisma.contact.update({
where: {id: contactId},
data: {subscribed: true},
});
// Track subscription event
const {EventService} = await import('./EventService.js');
await EventService.trackEvent(contact.projectId, 'contact.subscribed', contactId);
return contact;
}
/**
* PUBLIC: Unsubscribe a contact
*/
public static async unsubscribe(contactId: string): Promise<Contact> {
return prisma.contact.update({
const contact = await prisma.contact.update({
where: {id: contactId},
data: {subscribed: false},
});
// Track unsubscription event
const {EventService} = await import('./EventService.js');
await EventService.trackEvent(contact.projectId, 'contact.unsubscribed', contactId);
return contact;
}
/**
+18 -1
View File
@@ -459,17 +459,34 @@ export class EmailService {
case 'bounced':
updateData.status = EmailStatus.BOUNCED;
updateData.bouncedAt = now;
// Unsubscribe contact on bounce and track event
if (email.contactId) {
await prisma.contact.update({
where: {id: email.contactId},
data: {subscribed: false},
});
// Track unsubscription event
const {EventService} = await import('./EventService.js');
await EventService.trackEvent(email.projectId, 'contact.unsubscribed', email.contactId, email.id, {
reason: 'bounce',
});
}
break;
case 'complained':
updateData.status = EmailStatus.COMPLAINED;
updateData.complainedAt = now;
// Unsubscribe contact
// Unsubscribe contact and track event
if (email.contactId) {
await prisma.contact.update({
where: {id: email.contactId},
data: {subscribed: false},
});
// Track unsubscription event
const {EventService} = await import('./EventService.js');
await EventService.trackEvent(email.projectId, 'contact.unsubscribed', email.contactId, email.id, {
reason: 'complaint',
});
}
break;
}
@@ -401,10 +401,14 @@ describe('EmailService', () => {
expect(clicked?.clicks).toBe(1);
});
it('should transition to BOUNCED on bounce webhook', async () => {
it('should transition to BOUNCED on bounce webhook and unsubscribe contact', async () => {
const contact = await factories.createContact({
projectId,
subscribed: true,
});
const email = await factories.createEmail({
projectId,
contactId,
contactId: contact.id,
status: EmailStatus.SENT,
});
@@ -416,6 +420,23 @@ describe('EmailService', () => {
expect(bounced?.status).toBe(EmailStatus.BOUNCED);
expect(bounced?.bouncedAt).not.toBeNull();
// Verify contact was unsubscribed
const unsubscribedContact = await prisma.contact.findUnique({
where: {id: contact.id},
});
expect(unsubscribedContact?.subscribed).toBe(false);
// Verify unsubscription event was tracked
const event = await prisma.event.findFirst({
where: {
projectId,
contactId: contact.id,
name: 'contact.unsubscribed',
},
});
expect(event).not.toBeNull();
expect(event?.data).toMatchObject({reason: 'bounce'});
});
});
});