diff --git a/apps/api/package.json b/apps/api/package.json index f5d1586ba..c5df752fd 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -36,6 +36,7 @@ "@sthrift/service-cybersource": "workspace:*", "@sthrift/service-mongoose": "workspace:*", "@sthrift/service-otel": "workspace:*", + "@sthrift/service-sendgrid": "workspace:*", "@sthrift/service-token-validation": "workspace:*", "@sthrift/messaging-service-twilio": "workspace:*", "twilio": "^5.8.0" diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 76e941623..98e78e705 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -24,6 +24,7 @@ import { ServiceMessagingMock } from '@sthrift/messaging-service-mock'; import { graphHandlerCreator } from '@sthrift/graphql'; import { restHandlerCreator } from '@sthrift/rest'; import { ServiceCybersource } from '@sthrift/service-cybersource'; +import { SendGrid } from '@sthrift/service-sendgrid'; const { NODE_ENV } = process.env; const isDevelopment = NODE_ENV === 'development'; @@ -59,8 +60,9 @@ Cellix.initializeInfrastructureServices( ? serviceRegistry.getInfrastructureService(ServiceMessagingMock) : serviceRegistry.getInfrastructureService(ServiceMessagingTwilio); - const { domainDataSource } = dataSourcesFactory.withSystemPassport(); - RegisterEventHandlers(domainDataSource); + const sendGridService = new SendGrid('magic-link-email'); + + RegisterEventHandlers(dataSourcesFactory, sendGridService); return { dataSourcesFactory, diff --git a/packages/sthrift/domain/src/domain/contexts/reservation-request/reservation-request/reservation-request.ts b/packages/sthrift/domain/src/domain/contexts/reservation-request/reservation-request/reservation-request.ts index 4901354f8..8167f16c8 100644 --- a/packages/sthrift/domain/src/domain/contexts/reservation-request/reservation-request/reservation-request.ts +++ b/packages/sthrift/domain/src/domain/contexts/reservation-request/reservation-request/reservation-request.ts @@ -9,6 +9,7 @@ import type { ReservationRequestEntityReference, ReservationRequestProps, } from './reservation-request.entity.ts'; +import { ReservationRequestCreatedEvent } from '../../../events/types/reservation-request-created.ts'; export class ReservationRequest extends DomainSeedwork.AggregateRoot @@ -52,6 +53,17 @@ export class ReservationRequest instance.reservationPeriodStart = reservationPeriodStart; instance.reservationPeriodEnd = reservationPeriodEnd; instance.isNew = false; + + // Fire integration event for reservation request creation + instance.addIntegrationEvent(ReservationRequestCreatedEvent, { + reservationRequestId: newProps.id, + listingId: listing.id, + reserverId: reserver.id, + sharerId: listing.sharer.id, + reservationPeriodStart, + reservationPeriodEnd, + }); + return instance; } diff --git a/packages/sthrift/domain/src/domain/events/index.ts b/packages/sthrift/domain/src/domain/events/index.ts index b3c5f8a04..ddfad773f 100644 --- a/packages/sthrift/domain/src/domain/events/index.ts +++ b/packages/sthrift/domain/src/domain/events/index.ts @@ -1 +1,2 @@ -export { EventBusInstance } from './event-bus.ts'; \ No newline at end of file +export { EventBusInstance } from './event-bus.ts'; +export * from './types/index.ts'; \ No newline at end of file diff --git a/packages/sthrift/domain/src/domain/events/types/index.ts b/packages/sthrift/domain/src/domain/events/types/index.ts new file mode 100644 index 000000000..7cc145c96 --- /dev/null +++ b/packages/sthrift/domain/src/domain/events/types/index.ts @@ -0,0 +1 @@ +export * from './reservation-request-created.ts'; diff --git a/packages/sthrift/domain/src/domain/events/types/reservation-request-created.ts b/packages/sthrift/domain/src/domain/events/types/reservation-request-created.ts new file mode 100644 index 000000000..557f98873 --- /dev/null +++ b/packages/sthrift/domain/src/domain/events/types/reservation-request-created.ts @@ -0,0 +1,12 @@ +import { DomainSeedwork } from '@cellix/domain-seedwork'; + +export interface ReservationRequestCreatedProps { + reservationRequestId: string; + listingId: string; + reserverId: string; + sharerId: string; + reservationPeriodStart: Date; + reservationPeriodEnd: Date; +} + +export class ReservationRequestCreatedEvent extends DomainSeedwork.CustomDomainEventImpl {} diff --git a/packages/sthrift/domain/src/domain/index.ts b/packages/sthrift/domain/src/domain/index.ts index 217e5d920..a6ce21215 100644 --- a/packages/sthrift/domain/src/domain/index.ts +++ b/packages/sthrift/domain/src/domain/index.ts @@ -2,3 +2,4 @@ export * as Contexts from './contexts/index.ts'; export type { Services } from './services/index.ts'; export { type Passport, PassportFactory } from './contexts/passport.ts'; +export * from './events/index.ts'; diff --git a/packages/sthrift/domain/src/domain/services/index.ts b/packages/sthrift/domain/src/domain/services/index.ts index edce8fd61..ca6f8052c 100644 --- a/packages/sthrift/domain/src/domain/services/index.ts +++ b/packages/sthrift/domain/src/domain/services/index.ts @@ -1,5 +1,7 @@ import type { BlobStorage } from './blob-storage.ts'; +import type { TransactionalEmailService } from './transactional-email.ts'; export interface Services { BlobStorage: BlobStorage; + TransactionalEmailService: TransactionalEmailService; } \ No newline at end of file diff --git a/packages/sthrift/domain/src/domain/services/transactional-email.ts b/packages/sthrift/domain/src/domain/services/transactional-email.ts new file mode 100644 index 000000000..279a9dd79 --- /dev/null +++ b/packages/sthrift/domain/src/domain/services/transactional-email.ts @@ -0,0 +1,10 @@ + +export interface TransactionalEmailService { + sendReservationNotification( + recipientEmail: string, + reserverName: string, + listingTitle: string, + reservationStart: string, + reservationEnd: string, + ): Promise; +} diff --git a/packages/sthrift/event-handler/package.json b/packages/sthrift/event-handler/package.json index ac7422cb0..3aeaf93ac 100644 --- a/packages/sthrift/event-handler/package.json +++ b/packages/sthrift/event-handler/package.json @@ -20,7 +20,10 @@ "clean": "rimraf dist" }, "dependencies": { - "@sthrift/domain": "workspace:*" + "@sthrift/domain": "workspace:*", + "@sthrift/persistence": "workspace:*", + "@sthrift/service-sendgrid": "workspace:*", + "@cellix/event-bus-seedwork-node": "workspace:*" }, "devDependencies": { "@cellix/typescript-config": "workspace:*", diff --git a/packages/sthrift/event-handler/src/handlers/domain/index.ts b/packages/sthrift/event-handler/src/handlers/domain/index.ts index 134f339f0..cb75d4761 100644 --- a/packages/sthrift/event-handler/src/handlers/domain/index.ts +++ b/packages/sthrift/event-handler/src/handlers/domain/index.ts @@ -1,7 +1,7 @@ -import type { DomainDataSource } from '@sthrift/domain'; +import type { DataSourcesFactory } from '@sthrift/persistence'; export const RegisterDomainEventHandlers = ( - _domainDataSource: DomainDataSource + _dataSourcesFactory: DataSourcesFactory ): void => { /* Register domain event handlers */ }; diff --git a/packages/sthrift/event-handler/src/handlers/index.ts b/packages/sthrift/event-handler/src/handlers/index.ts index 08253a3c6..533ddccda 100644 --- a/packages/sthrift/event-handler/src/handlers/index.ts +++ b/packages/sthrift/event-handler/src/handlers/index.ts @@ -1,10 +1,12 @@ -import type { DomainDataSource } from "@sthrift/domain"; +import { Domain } from '@sthrift/domain'; +import type { DataSourcesFactory } from '@sthrift/persistence'; import { RegisterDomainEventHandlers } from "./domain/index.ts"; import { RegisterIntegrationEventHandlers } from "./integration/index.ts"; export const RegisterEventHandlers = ( - domainDataSource: DomainDataSource + dataSourcesFactory: DataSourcesFactory, + emailService: Domain.Services['TransactionalEmailService'] ) => { - RegisterDomainEventHandlers(domainDataSource); - RegisterIntegrationEventHandlers(domainDataSource); + RegisterDomainEventHandlers(dataSourcesFactory); + RegisterIntegrationEventHandlers(dataSourcesFactory, emailService); } \ No newline at end of file diff --git a/packages/sthrift/event-handler/src/handlers/integration/index.ts b/packages/sthrift/event-handler/src/handlers/integration/index.ts index 3044527de..53eb55106 100644 --- a/packages/sthrift/event-handler/src/handlers/integration/index.ts +++ b/packages/sthrift/event-handler/src/handlers/integration/index.ts @@ -1,7 +1,10 @@ -import type { DomainDataSource } from '@sthrift/domain'; +import { Domain } from '@sthrift/domain'; +import type { DataSourcesFactory } from '@sthrift/persistence'; +import { registerReservationRequestCreatedHandler } from './reservation-request-created--notify-sharer.ts'; export const RegisterIntegrationEventHandlers = ( - domainDataSource: DomainDataSource, + dataSourcesFactory: DataSourcesFactory, + emailService: Domain.Services['TransactionalEmailService'], ): void => { - console.log(domainDataSource); + registerReservationRequestCreatedHandler(dataSourcesFactory, emailService); }; diff --git a/packages/sthrift/event-handler/src/handlers/integration/reservation-request-created--notify-sharer.ts b/packages/sthrift/event-handler/src/handlers/integration/reservation-request-created--notify-sharer.ts new file mode 100644 index 000000000..2d4b7874f --- /dev/null +++ b/packages/sthrift/event-handler/src/handlers/integration/reservation-request-created--notify-sharer.ts @@ -0,0 +1,104 @@ +import { Domain } from '@sthrift/domain'; +import type { DataSourcesFactory } from '@sthrift/persistence'; +import { NodeEventBusInstance } from '@cellix/event-bus-seedwork-node'; + +export const ReservationRequestCreatedNotifySharerHandler = ( + dataSourcesFactory: DataSourcesFactory, + emailService: Domain.Services['TransactionalEmailService'], +) => { + return async (payload: { + reservationRequestId: string; + listingId: string; + reserverId: string; + sharerId: string; + reservationPeriodStart: Date; + reservationPeriodEnd: Date; + }) => { + try { + console.log( + 'ReservationRequestCreatedNotifySharerHandler: Processing event for reservation request', + payload.reservationRequestId, + ); + + // Get the datasources with system passport to fetch user information + const { readonlyDataSource } = dataSourcesFactory.withSystemPassport(); + + // Fetch the sharer (listing owner) details + const sharer = await readonlyDataSource.User.PersonalUser.PersonalUserReadRepo.getById( + payload.sharerId, + ); + + if (!sharer) { + console.error( + `Sharer with ID ${payload.sharerId} not found for reservation request ${payload.reservationRequestId}`, + ); + return; + } + + // Fetch the reserver details + const reserver = + await readonlyDataSource.User.PersonalUser.PersonalUserReadRepo.getById( + payload.reserverId, + ); + + if (!reserver) { + console.error( + `Reserver with ID ${payload.reserverId} not found for reservation request ${payload.reservationRequestId}`, + ); + return; + } + + // Fetch the listing details + const listing = + await readonlyDataSource.Listing.ItemListing.ItemListingReadRepo.getById( + payload.listingId, + ); + + if (!listing) { + console.error( + `Listing with ID ${payload.listingId} not found for reservation request ${payload.reservationRequestId}`, + ); + return; + } + + // Prepare email notification data + const sharerEmail = sharer.account.email; + const reserverName = reserver.account.username || reserver.account.email; + const listingTitle = listing.title; + const reservationStart = payload.reservationPeriodStart.toLocaleDateString(); + const reservationEnd = payload.reservationPeriodEnd.toLocaleDateString(); + + console.log( + `Sending reservation notification email to ${sharerEmail} for listing "${listingTitle}"`, + ); + + // Call the email service to send the notification + await emailService.sendReservationNotification( + sharerEmail, + reserverName, + listingTitle, + reservationStart, + reservationEnd, + ); + } catch (error) { + console.error( + 'Error in ReservationRequestCreatedNotifySharerHandler:', + error, + ); + // Don't throw - integration events should not affect the main transaction + } + }; +}; + +export const registerReservationRequestCreatedHandler = ( + dataSourcesFactory: DataSourcesFactory, + emailService: Domain.Services['TransactionalEmailService'], +) => { + NodeEventBusInstance.register( + Domain.ReservationRequestCreatedEvent, + ReservationRequestCreatedNotifySharerHandler( + dataSourcesFactory, + emailService, + ), + ); +}; diff --git a/packages/sthrift/service-sendgrid/package.json b/packages/sthrift/service-sendgrid/package.json index 87c5e57d4..7fde2be93 100644 --- a/packages/sthrift/service-sendgrid/package.json +++ b/packages/sthrift/service-sendgrid/package.json @@ -30,6 +30,7 @@ "@cellix/typescript-config": "workspace:*" }, "dependencies": { - "@sendgrid/mail": "^8.0.0" + "@sendgrid/mail": "^8.0.0", + "@sthrift/domain": "workspace:*" } } \ No newline at end of file diff --git a/packages/sthrift/service-sendgrid/src/sendgrid.ts b/packages/sthrift/service-sendgrid/src/sendgrid.ts index fde349972..288875c60 100644 --- a/packages/sthrift/service-sendgrid/src/sendgrid.ts +++ b/packages/sthrift/service-sendgrid/src/sendgrid.ts @@ -3,7 +3,18 @@ import { readHtmlFile } from './get-email-template.js'; import fs from 'fs'; import path from 'path'; -export default class SendGrid { +// TransactionalEmailService interface definition +interface TransactionalEmailService { + sendReservationNotification( + recipientEmail: string, + reserverName: string, + listingTitle: string, + reservationStart: string, + reservationEnd: string, + ): Promise; +} + +export default class SendGrid implements TransactionalEmailService { emailTemplateName: string; constructor(emailTemplateName: string) { @@ -15,6 +26,56 @@ export default class SendGrid { this.emailTemplateName = emailTemplateName; } + async sendReservationNotification( + recipientEmail: string, + reserverName: string, + listingTitle: string, + reservationStart: string, + reservationEnd: string, + ): Promise { + console.log('SendGrid.sendReservationNotification()'); + console.log(` To: ${recipientEmail}`); + console.log(` Reserver: ${reserverName}`); + console.log(` Listing: ${listingTitle}`); + console.log(` Period: ${reservationStart} - ${reservationEnd}`); + + if (process.env["NODE_ENV"] === 'development') { + const outDir = path.join(process.cwd(), 'tmp-emails'); + if (!fs.existsSync(outDir)) fs.mkdirSync(outDir); + + const sanitizedEmail = recipientEmail.replace(/[@/\\:*?"<>|]/g, '_'); + const outFile = path.join(outDir, `reservation_${sanitizedEmail}_${Date.now()}.txt`); + const content = ` +Reservation Notification +======================== +To: ${recipientEmail} +Reserver: ${reserverName} +Listing: ${listingTitle} +Start Date: ${reservationStart} +End Date: ${reservationEnd} +`; + fs.writeFileSync(outFile, content, 'utf-8'); + console.log(`Email saved to ${outFile}`); + return; + } + + // TODO: Implement actual email sending with template + // For production, this should use a proper email template + try { + await sendgrid.send({ + to: recipientEmail, + from: process.env['SENDGRID_FROM_EMAIL'] || 'noreply@sharethrift.com', + subject: `New reservation request for ${listingTitle}`, + text: `${reserverName} has requested to reserve ${listingTitle} from ${reservationStart} to ${reservationEnd}.`, + html: `

${reserverName} has requested to reserve ${listingTitle} from ${reservationStart} to ${reservationEnd}.

`, + }); + console.log('Reservation notification email sent successfully'); + } catch (error) { + console.error('Error sending reservation notification email:', error); + throw error; + } + } + sendEmailWithMagicLink = async (userEmail: string, magicLink: string) => { console.log('SendGrid.sendEmail() - email: ', userEmail); let template: { fromEmail: string; subject: string; body: string }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5fba730e4..555553d60 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -143,6 +143,9 @@ importers: '@sthrift/service-otel': specifier: workspace:* version: link:../../packages/sthrift/service-otel + '@sthrift/service-sendgrid': + specifier: workspace:* + version: link:../../packages/sthrift/service-sendgrid '@sthrift/service-token-validation': specifier: workspace:* version: link:../../packages/sthrift/service-token-validation @@ -756,9 +759,18 @@ importers: packages/sthrift/event-handler: dependencies: + '@cellix/event-bus-seedwork-node': + specifier: workspace:* + version: link:../../cellix/event-bus-seedwork-node '@sthrift/domain': specifier: workspace:* version: link:../domain + '@sthrift/persistence': + specifier: workspace:* + version: link:../persistence + '@sthrift/service-sendgrid': + specifier: workspace:* + version: link:../service-sendgrid devDependencies: '@cellix/typescript-config': specifier: workspace:* @@ -1146,6 +1158,9 @@ importers: '@sendgrid/mail': specifier: ^8.0.0 version: 8.1.6 + '@sthrift/domain': + specifier: workspace:* + version: link:../domain react: specifier: '>=17.0.0' version: 19.2.0