From 2ef463ce5ebe6195cf5d493219a6b18efd0a246e Mon Sep 17 00:00:00 2001 From: Dries Augustyns Date: Tue, 2 Dec 2025 09:56:27 +0100 Subject: [PATCH] Added events for subscribe and unsubscribe --- apps/api/src/services/ContactService.ts | 56 +++++++++++++++++-- apps/api/src/services/EmailService.ts | 19 ++++++- .../services/__tests__/EmailService.test.ts | 25 ++++++++- 3 files changed, 92 insertions(+), 8 deletions(-) diff --git a/apps/api/src/services/ContactService.ts b/apps/api/src/services/ContactService.ts index c0088d4..e87aa6e 100644 --- a/apps/api/src/services/ContactService.ts +++ b/apps/api/src/services/ContactService.ts @@ -128,7 +128,7 @@ export class ContactService { data: {email?: string; data?: Prisma.JsonValue; subscribed?: boolean}, ): Promise { // 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 { - 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 { - 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; } /** diff --git a/apps/api/src/services/EmailService.ts b/apps/api/src/services/EmailService.ts index 572bd96..ca71428 100644 --- a/apps/api/src/services/EmailService.ts +++ b/apps/api/src/services/EmailService.ts @@ -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; } diff --git a/apps/api/src/services/__tests__/EmailService.test.ts b/apps/api/src/services/__tests__/EmailService.test.ts index 977a8b8..4308f2e 100644 --- a/apps/api/src/services/__tests__/EmailService.test.ts +++ b/apps/api/src/services/__tests__/EmailService.test.ts @@ -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'}); }); }); });