Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
6 changes: 4 additions & 2 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -59,8 +60,9 @@ Cellix.initializeInfrastructureServices<ApiContextSpec, ApplicationServices>(
? serviceRegistry.getInfrastructureService<MessagingService>(ServiceMessagingMock)
: serviceRegistry.getInfrastructureService<MessagingService>(ServiceMessagingTwilio);

const { domainDataSource } = dataSourcesFactory.withSystemPassport();
RegisterEventHandlers(domainDataSource);
const sendGridService = new SendGrid('magic-link-email');

RegisterEventHandlers(dataSourcesFactory, sendGridService);

return {
dataSourcesFactory,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<props extends ReservationRequestProps>
extends DomainSeedwork.AggregateRoot<props, Passport>
Expand Down Expand Up @@ -52,6 +53,17 @@ export class ReservationRequest<props extends ReservationRequestProps>
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;
}

Expand Down
3 changes: 2 additions & 1 deletion packages/sthrift/domain/src/domain/events/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { EventBusInstance } from './event-bus.ts';
export { EventBusInstance } from './event-bus.ts';
export * from './types/index.ts';
1 change: 1 addition & 0 deletions packages/sthrift/domain/src/domain/events/types/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './reservation-request-created.ts';
Original file line number Diff line number Diff line change
@@ -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<ReservationRequestCreatedProps> {}
1 change: 1 addition & 0 deletions packages/sthrift/domain/src/domain/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
2 changes: 2 additions & 0 deletions packages/sthrift/domain/src/domain/services/index.ts
Original file line number Diff line number Diff line change
@@ -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;
}
10 changes: 10 additions & 0 deletions packages/sthrift/domain/src/domain/services/transactional-email.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@

export interface TransactionalEmailService {
sendReservationNotification(
recipientEmail: string,
reserverName: string,
listingTitle: string,
reservationStart: string,
reservationEnd: string,
): Promise<void>;
}
5 changes: 4 additions & 1 deletion packages/sthrift/event-handler/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
4 changes: 2 additions & 2 deletions packages/sthrift/event-handler/src/handlers/domain/index.ts
Original file line number Diff line number Diff line change
@@ -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 */
};
Expand Down
10 changes: 6 additions & 4 deletions packages/sthrift/event-handler/src/handlers/index.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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);
};
Original file line number Diff line number Diff line change
@@ -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,
),
);
};
3 changes: 2 additions & 1 deletion packages/sthrift/service-sendgrid/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"@cellix/typescript-config": "workspace:*"
},
"dependencies": {
"@sendgrid/mail": "^8.0.0"
"@sendgrid/mail": "^8.0.0",
"@sthrift/domain": "workspace:*"
}
}
63 changes: 62 additions & 1 deletion packages/sthrift/service-sendgrid/src/sendgrid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
}

export default class SendGrid implements TransactionalEmailService {
emailTemplateName: string;

constructor(emailTemplateName: string) {
Expand All @@ -15,6 +26,56 @@ export default class SendGrid {
this.emailTemplateName = emailTemplateName;
}

async sendReservationNotification(
recipientEmail: string,
reserverName: string,
listingTitle: string,
reservationStart: string,
reservationEnd: string,
): Promise<void> {
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: `<p><strong>${reserverName}</strong> has requested to reserve <strong>${listingTitle}</strong> from ${reservationStart} to ${reservationEnd}.</p>`,
});
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 };
Expand Down
15 changes: 15 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading