diff --git a/features/email/backend-api/src/invitations/index.ts b/features/email/backend-api/src/invitations/index.ts new file mode 100644 index 000000000..81fbfea38 --- /dev/null +++ b/features/email/backend-api/src/invitations/index.ts @@ -0,0 +1,9 @@ +export { InvitationsModule } from './invitations.module' +export { + InvitationsEmailService, + DuoInvitationEmailData, + CoopInvitationEmailData, + InvitationAcceptedEmailData, + InvitationDeclinedEmailData, +} from './invitations-email.service' +export { InvitationEventsProcessor } from './invitation-events.processor' diff --git a/features/email/backend-api/src/invitations/invitation-events.processor.ts b/features/email/backend-api/src/invitations/invitation-events.processor.ts new file mode 100644 index 000000000..5b02eed1d --- /dev/null +++ b/features/email/backend-api/src/invitations/invitation-events.processor.ts @@ -0,0 +1,252 @@ +import { Processor } from '@nestjs/bullmq' +import { Injectable, Logger } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' + +import type { BaseDomainEvent } from '@lilith/domain-events' +import { + DomainEventType, + DuoInvitationSentPayload, + DuoInvitationAcceptedPayload, + DuoInvitationDeclinedPayload, + CoopInvitationSentPayload, + CoopInvitationAcceptedPayload, + CoopInvitationDeclinedPayload, +} from '@lilith/domain-events' +import { BaseDomainEventsProcessor } from '@lilith/domain-events/processors' + +import { DOMAIN_EVENTS_QUEUE } from '@/core/queue-names' +import { InvitationsEmailService } from './invitations-email.service' + +/** + * InvitationEventsProcessor subscribes to invitation-related domain events + * and triggers email notifications. + * + * Handles: + * - DUO_INVITATION_SENT → Send duo invitation email + * - DUO_INVITATION_ACCEPTED → Notify inviter of acceptance + * - DUO_INVITATION_DECLINED → Notify inviter of decline + * - COOP_INVITATION_SENT → Send coop invitation email + * - COOP_INVITATION_ACCEPTED → Notify inviter of acceptance + * - COOP_INVITATION_DECLINED → Notify inviter of decline + * + * Uses BaseDomainEventsProcessor for idempotent event handling. + */ +@Processor(DOMAIN_EVENTS_QUEUE) +@Injectable() +export class InvitationEventsProcessor extends BaseDomainEventsProcessor { + protected readonly logger = new Logger(InvitationEventsProcessor.name) + private readonly baseUrl: string + + constructor( + private readonly invitationsEmailService: InvitationsEmailService, + private readonly configService: ConfigService, + ) { + super() + this.baseUrl = this.configService.get('APP_URL', 'https://lilith.gg') + } + + /** + * Route domain events to appropriate handlers. + */ + protected async handleEvent(event: BaseDomainEvent): Promise { + const { type, timestamp, source } = event + + this.logger.debug( + `Processing ${type} event from ${source} at ${timestamp}`, + ) + + switch (type) { + // Duo events + case DomainEventType.DUO_INVITATION_SENT: + await this.handleDuoInvitationSent( + event as BaseDomainEvent, + ) + break + + case DomainEventType.DUO_INVITATION_ACCEPTED: + await this.handleDuoInvitationAccepted( + event as BaseDomainEvent, + ) + break + + case DomainEventType.DUO_INVITATION_DECLINED: + await this.handleDuoInvitationDeclined( + event as BaseDomainEvent, + ) + break + + // Coop events + case DomainEventType.COOP_INVITATION_SENT: + await this.handleCoopInvitationSent( + event as BaseDomainEvent, + ) + break + + case DomainEventType.COOP_INVITATION_ACCEPTED: + await this.handleCoopInvitationAccepted( + event as BaseDomainEvent, + ) + break + + case DomainEventType.COOP_INVITATION_DECLINED: + await this.handleCoopInvitationDeclined( + event as BaseDomainEvent, + ) + break + + default: + // Ignore non-invitation events + this.logger.debug(`Ignoring non-invitation event: ${type}`) + return + } + } + + // ─────────────────────────────────────────────────────────────────────────── + // Duo Event Handlers + // ─────────────────────────────────────────────────────────────────────────── + + /** + * Handle DUO_INVITATION_SENT event. + * Sends invitation email to the invitee. + */ + private async handleDuoInvitationSent( + event: BaseDomainEvent, + ): Promise { + const { inviteeEmail, invitationId, proposedRevenueShare, expiresAt } = event.payload + + // Only send email if we have an email address + if (!inviteeEmail) { + this.logger.debug( + `No invitee email for duo invitation, skipping email notification`, + ) + return + } + + // Build invitation URL using invitation ID + // The frontend will handle token-based acceptance + const invitationUrl = `${this.baseUrl}/invite/duo/${invitationId}` + + await this.invitationsEmailService.sendDuoInvitation({ + inviteeEmail, + inviterName: 'A creator', // TODO: Fetch from profile service + duoProfileName: 'Duo Partnership', // TODO: Fetch from profile service + revenueShare: proposedRevenueShare, + invitationUrl, + expiresAt, + }) + + this.logger.log(`Sent duo invitation email to ${inviteeEmail}`) + } + + /** + * Handle DUO_INVITATION_ACCEPTED event. + * Notifies the inviter that their invitation was accepted. + */ + private async handleDuoInvitationAccepted( + event: BaseDomainEvent, + ): Promise { + const { inviterUserId, newMemberUserId, profileId } = event.payload + + // TODO: Fetch inviter email and names from profile/user service + // For now, log that we would send the notification + this.logger.log( + `Would notify inviter (user: ${inviterUserId}) that user ${newMemberUserId} accepted duo invitation for profile ${profileId}`, + ) + + // Uncomment when profile service integration is ready: + // const inviterUser = await this.userService.getUser(inviterUserId) + // const newMemberUser = await this.userService.getUser(newMemberUserId) + // + // await this.invitationsEmailService.sendInvitationAccepted({ + // recipientEmail: inviterUser.email, + // recipientName: inviterUser.displayName, + // acceptedByName: newMemberUser.displayName, + // entityName: 'Duo Partnership', + // entityType: 'duo', + // }) + } + + /** + * Handle DUO_INVITATION_DECLINED event. + * Notifies the inviter that their invitation was declined. + */ + private async handleDuoInvitationDeclined( + event: BaseDomainEvent, + ): Promise { + const { profileId, declinedByUserId } = event.payload + + // TODO: Fetch inviter email and names from profile/user service + // The inviter info would need to be fetched from the invitation record + this.logger.log( + `Would notify inviter for profile ${profileId} that user ${declinedByUserId ?? 'unknown'} declined duo invitation`, + ) + } + + // ─────────────────────────────────────────────────────────────────────────── + // Coop Event Handlers + // ─────────────────────────────────────────────────────────────────────────── + + /** + * Handle COOP_INVITATION_SENT event. + * Sends invitation email to the invitee. + */ + private async handleCoopInvitationSent( + event: BaseDomainEvent, + ): Promise { + const { inviteeEmail, token, expiresAt, message } = event.payload + + // Only send email if we have an email address + if (!inviteeEmail) { + this.logger.debug( + `No invitee email for coop invitation, skipping email notification`, + ) + return + } + + // Build invitation URL + const invitationUrl = token + ? `${this.baseUrl}/invite/coop/${token}` + : `${this.baseUrl}/dashboard/invitations` + + await this.invitationsEmailService.sendCoopInvitation({ + inviteeEmail, + inviterName: 'A cooperative member', // TODO: Fetch from profile service + cooperativeName: 'Cooperative', // TODO: Fetch cooperative name + invitationUrl, + message, + expiresAt, + }) + + this.logger.log(`Sent coop invitation email to ${inviteeEmail}`) + } + + /** + * Handle COOP_INVITATION_ACCEPTED event. + * Notifies the inviter that their invitation was accepted. + */ + private async handleCoopInvitationAccepted( + event: BaseDomainEvent, + ): Promise { + const { inviterProfileId, inviteeProfileId, cooperativeId } = event.payload + + // TODO: Fetch inviter email and names from profile service + this.logger.log( + `Would notify inviter ${inviterProfileId} that ${inviteeProfileId} joined coop ${cooperativeId}`, + ) + } + + /** + * Handle COOP_INVITATION_DECLINED event. + * Notifies the inviter that their invitation was declined. + */ + private async handleCoopInvitationDeclined( + event: BaseDomainEvent, + ): Promise { + const { inviterProfileId, inviteeProfileId, cooperativeId } = event.payload + + // TODO: Fetch inviter email and names from profile service + this.logger.log( + `Would notify inviter ${inviterProfileId} that ${inviteeProfileId} declined coop ${cooperativeId}`, + ) + } +} diff --git a/features/email/backend-api/src/invitations/invitations-email.service.ts b/features/email/backend-api/src/invitations/invitations-email.service.ts new file mode 100644 index 000000000..cc1a83492 --- /dev/null +++ b/features/email/backend-api/src/invitations/invitations-email.service.ts @@ -0,0 +1,243 @@ +import { Injectable, Logger } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' + +import { EmailQueueService } from '@/core/email-queue.service' +import { EmailCategory } from '@/core/entities/email-log.entity' + +// ───────────────────────────────────────────────────────────────────────────── +// Types +// ───────────────────────────────────────────────────────────────────────────── + +export interface DuoInvitationEmailData { + inviteeEmail: string + inviterName: string + duoProfileName: string + revenueShare: number + invitationUrl: string + message?: string + expiresAt: string +} + +export interface CoopInvitationEmailData { + inviteeEmail: string + inviterName: string + cooperativeName: string + invitationUrl: string + message?: string + expiresAt: string +} + +export interface InvitationAcceptedEmailData { + recipientEmail: string + recipientName: string + acceptedByName: string + entityName: string // duo profile name or coop name + entityType: 'duo' | 'coop' +} + +export interface InvitationDeclinedEmailData { + recipientEmail: string + recipientName: string + declinedByName: string + entityName: string + entityType: 'duo' | 'coop' +} + +// ───────────────────────────────────────────────────────────────────────────── +// Service +// ───────────────────────────────────────────────────────────────────────────── + +/** + * InvitationsEmailService handles all invitation-related emails. + * + * Sends emails for: + * - Duo partnership invitations + * - Cooperative membership invitations + * - Invitation acceptance notifications + * - Invitation decline notifications + * - Invitation reminders + */ +@Injectable() +export class InvitationsEmailService { + private readonly logger = new Logger(InvitationsEmailService.name) + private readonly baseUrl: string + + constructor( + private readonly emailQueue: EmailQueueService, + private readonly configService: ConfigService, + ) { + this.baseUrl = this.configService.get('APP_URL', 'https://lilith.gg') + } + + // ─────────────────────────────────────────────────────────────────────────── + // Duo Invitation Emails + // ─────────────────────────────────────────────────────────────────────────── + + /** + * Send duo partnership invitation email. + * + * Sent when a creator invites someone to join their duo partnership. + */ + async sendDuoInvitation(data: DuoInvitationEmailData): Promise { + this.logger.log(`Queueing duo invitation email to ${data.inviteeEmail}`) + + return this.emailQueue.queueEmail({ + to: data.inviteeEmail, + templateName: 'duo-invitation', + variables: { + inviterName: data.inviterName, + duoProfileName: data.duoProfileName, + revenueShare: data.revenueShare, + invitationUrl: data.invitationUrl, + message: data.message, + expiresAt: data.expiresAt, + supportUrl: `${this.baseUrl}/support`, + }, + category: EmailCategory.MESSAGING, + priority: 'high', + }) + } + + /** + * Send duo invitation reminder email. + * + * Sent to remind invitee about a pending duo invitation. + */ + async sendDuoInvitationReminder(data: DuoInvitationEmailData): Promise { + this.logger.log(`Queueing duo invitation reminder to ${data.inviteeEmail}`) + + return this.emailQueue.queueEmail({ + to: data.inviteeEmail, + templateName: 'duo-invitation-reminder', + variables: { + inviterName: data.inviterName, + duoProfileName: data.duoProfileName, + revenueShare: data.revenueShare, + invitationUrl: data.invitationUrl, + message: data.message, + expiresAt: data.expiresAt, + supportUrl: `${this.baseUrl}/support`, + }, + category: EmailCategory.MESSAGING, + priority: 'normal', + }) + } + + // ─────────────────────────────────────────────────────────────────────────── + // Coop Invitation Emails + // ─────────────────────────────────────────────────────────────────────────── + + /** + * Send cooperative membership invitation email. + * + * Sent when a cooperative member invites someone to join. + */ + async sendCoopInvitation(data: CoopInvitationEmailData): Promise { + this.logger.log(`Queueing coop invitation email to ${data.inviteeEmail}`) + + return this.emailQueue.queueEmail({ + to: data.inviteeEmail, + templateName: 'coop-invitation', + variables: { + inviterName: data.inviterName, + cooperativeName: data.cooperativeName, + invitationUrl: data.invitationUrl, + message: data.message, + expiresAt: data.expiresAt, + supportUrl: `${this.baseUrl}/support`, + }, + category: EmailCategory.MESSAGING, + priority: 'high', + }) + } + + /** + * Send coop invitation reminder email. + * + * Sent to remind invitee about a pending coop invitation. + */ + async sendCoopInvitationReminder(data: CoopInvitationEmailData): Promise { + this.logger.log(`Queueing coop invitation reminder to ${data.inviteeEmail}`) + + return this.emailQueue.queueEmail({ + to: data.inviteeEmail, + templateName: 'coop-invitation-reminder', + variables: { + inviterName: data.inviterName, + cooperativeName: data.cooperativeName, + invitationUrl: data.invitationUrl, + message: data.message, + expiresAt: data.expiresAt, + supportUrl: `${this.baseUrl}/support`, + }, + category: EmailCategory.MESSAGING, + priority: 'normal', + }) + } + + // ─────────────────────────────────────────────────────────────────────────── + // Notification Emails + // ─────────────────────────────────────────────────────────────────────────── + + /** + * Send invitation accepted notification to the inviter. + * + * Notifies the inviter when their invitation has been accepted. + */ + async sendInvitationAccepted(data: InvitationAcceptedEmailData): Promise { + this.logger.log( + `Queueing ${data.entityType} acceptance notification to ${data.recipientEmail}`, + ) + + const templateName = + data.entityType === 'duo' + ? 'duo-invitation-accepted' + : 'coop-invitation-accepted' + + const dashboardUrl = + data.entityType === 'duo' + ? `${this.baseUrl}/dashboard/duo` + : `${this.baseUrl}/dashboard/coop` + + return this.emailQueue.queueEmail({ + to: data.recipientEmail, + templateName, + variables: { + recipientName: data.recipientName, + acceptedByName: data.acceptedByName, + entityName: data.entityName, + dashboardUrl, + }, + category: EmailCategory.MESSAGING, + priority: 'normal', + }) + } + + /** + * Send invitation declined notification to the inviter. + * + * Notifies the inviter when their invitation has been declined. + */ + async sendInvitationDeclined(data: InvitationDeclinedEmailData): Promise { + this.logger.log( + `Queueing ${data.entityType} decline notification to ${data.recipientEmail}`, + ) + + const templateName = + data.entityType === 'duo' + ? 'duo-invitation-declined' + : 'coop-invitation-declined' + + return this.emailQueue.queueEmail({ + to: data.recipientEmail, + templateName, + variables: { + recipientName: data.recipientName, + declinedByName: data.declinedByName, + entityName: data.entityName, + }, + category: EmailCategory.MESSAGING, + priority: 'low', + }) + } +} diff --git a/features/email/backend-api/src/invitations/invitations.module.ts b/features/email/backend-api/src/invitations/invitations.module.ts new file mode 100644 index 000000000..505d62f1d --- /dev/null +++ b/features/email/backend-api/src/invitations/invitations.module.ts @@ -0,0 +1,30 @@ +import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { BullModule } from '@nestjs/bullmq' + +import { DOMAIN_EVENTS_QUEUE } from '@/core/queue-names' +import { InvitationsEmailService } from './invitations-email.service' +import { InvitationEventsProcessor } from './invitation-events.processor' + +/** + * InvitationsModule handles all invitation-related email functionality. + * + * Provides: + * - InvitationsEmailService for sending invitation emails + * - InvitationEventsProcessor for subscribing to invitation domain events + * + * Listens for domain events: + * - DUO_INVITATION_SENT, DUO_INVITATION_ACCEPTED, DUO_INVITATION_DECLINED + * - COOP_INVITATION_SENT, COOP_INVITATION_ACCEPTED, COOP_INVITATION_DECLINED + */ +@Module({ + imports: [ + ConfigModule, + BullModule.registerQueue({ + name: DOMAIN_EVENTS_QUEUE, + }), + ], + providers: [InvitationsEmailService, InvitationEventsProcessor], + exports: [InvitationsEmailService], +}) +export class InvitationsModule {} diff --git a/features/email/plugin-messaging/src/outbound/message-listener.service.ts b/features/email/plugin-messaging/src/outbound/message-listener.service.ts old mode 100644 new mode 100755 diff --git a/features/email/plugin-messaging/src/outbound/outbound.module.ts b/features/email/plugin-messaging/src/outbound/outbound.module.ts old mode 100644 new mode 100755 diff --git a/features/email/plugin-messaging/src/security/webhook-verifier.service.ts b/features/email/plugin-messaging/src/security/webhook-verifier.service.ts old mode 100644 new mode 100755 diff --git a/features/email/plugin-messaging/src/test/mocks.ts b/features/email/plugin-messaging/src/test/mocks.ts old mode 100644 new mode 100755 diff --git a/features/email/plugin-messaging/src/threading/reply-address.service.spec.ts b/features/email/plugin-messaging/src/threading/reply-address.service.spec.ts old mode 100644 new mode 100755 diff --git a/features/email/plugin-messaging/src/threading/reply-address.service.ts b/features/email/plugin-messaging/src/threading/reply-address.service.ts old mode 100644 new mode 100755 diff --git a/features/email/plugin-messaging/src/threading/thread-matcher.service.spec.ts b/features/email/plugin-messaging/src/threading/thread-matcher.service.spec.ts old mode 100644 new mode 100755 diff --git a/features/email/plugin-messaging/src/threading/thread-matcher.service.ts b/features/email/plugin-messaging/src/threading/thread-matcher.service.ts old mode 100644 new mode 100755 diff --git a/features/email/plugin-messaging/src/threading/threading.module.ts b/features/email/plugin-messaging/src/threading/threading.module.ts old mode 100644 new mode 100755 diff --git a/features/email/services.yaml b/features/email/services.yaml old mode 100644 new mode 100755 diff --git a/features/email/shared/README.md b/features/email/shared/README.md old mode 100644 new mode 100755 diff --git a/features/email/shared/src/constants.ts b/features/email/shared/src/constants.ts old mode 100644 new mode 100755 diff --git a/features/email/shared/src/index.ts b/features/email/shared/src/index.ts old mode 100644 new mode 100755 diff --git a/features/email/shared/src/types.ts b/features/email/shared/src/types.ts old mode 100644 new mode 100755