diff --git a/apps/api/src/notifications/email/email.module.ts b/apps/api/src/notifications/email/email.module.ts index 9eb5a32..343780d 100644 --- a/apps/api/src/notifications/email/email.module.ts +++ b/apps/api/src/notifications/email/email.module.ts @@ -1,30 +1,25 @@ import { Module } from '@nestjs/common'; import { EmailService } from './email.service'; -import { MockEmailAdapter } from './adapters/mock.adapter'; -import { SendGridEmailAdapter } from './adapters/sendgrid.adapter'; +import { EmailTemplateService } from './templates/template.service'; +import { EmailAdapter } from "./adapters/email.adapter", +import { MockEmailProvider } from './providers/mock.provider'; +import { SendGridEmailProvider } from './providers/sendgrid.provider'; @Module({ providers: [ + EmailService, + EmailTemplateService, { - provide: 'EMAIL_ADAPTER', + provide: EmailAdapter, useFactory: () => { - const provider = process.env.EMAIL_PROVIDER; - - if (provider === 'sendgrid') { - return new SendGridEmailAdapter( - process.env.SENDGRID_API_KEY!, - process.env.EMAIL_FROM!, - ); + switch (process.env.EMAIL_PROVIDER) { + case 'sendgrid': + return new SendGridEmailProvider(); + default: + return new MockEmailProvider(); } - - return new MockEmailAdapter(); }, }, - { - provide: EmailService, - useFactory: (adapter) => new EmailService(adapter), - inject: ['EMAIL_ADAPTER'], - }, ], exports: [EmailService], }) diff --git a/apps/api/src/notifications/email/email.service.spec.ts b/apps/api/src/notifications/email/email.service.spec.ts new file mode 100644 index 0000000..13d776e --- /dev/null +++ b/apps/api/src/notifications/email/email.service.spec.ts @@ -0,0 +1,20 @@ +it('renders template and sends email', async () => { + const adapter = { send: jest.fn() }; + const templates = { + render: jest.fn().mockReturnValue(''), + }; + + const service = new EmailService( + adapter as any, + templates as any, + ); + + await service.sendEmail({ + to: 'test@mail.com', + subject: 'Hello', + template: 'booking-confirmed', + data: { name: 'MD' }, + }); + + expect(adapter.send).toHaveBeenCalled(); +}); diff --git a/apps/api/src/notifications/email/email.service.ts b/apps/api/src/notifications/email/email.service.ts index 4abfaa8..7907d0e 100644 --- a/apps/api/src/notifications/email/email.service.ts +++ b/apps/api/src/notifications/email/email.service.ts @@ -1,70 +1,37 @@ import { Injectable, Logger } from '@nestjs/common'; +import { SendEmailInput } from './email.types'; +import { EmailTemplateService } from './templates/template.service'; import { EmailAdapter } from './adapters/email.adapter'; -import { SendEmailPayload } from './email.types'; -import { EmailLogger } from './email.logger'; - -import * as Handlebars from 'handlebars'; -import * as fs from 'fs'; -import * as path from 'path'; @Injectable() export class EmailService { private readonly logger = new Logger(EmailService.name); - constructor(private readonly adapter: EmailAdapter) {} + constructor( + private readonly adapter: EmailAdapter, + private readonly templates: EmailTemplateService, + ) {} - async sendEmail(payload: SendEmailPayload) { - const html = this.renderTemplate(payload.template, payload.data); + async sendEmail(input: SendEmailInput): Promise { + try { + const html = this.templates.render( + input.template, + input.data, + ); - const result = await this.adapter.send({ - ...payload, - data: { html }, - }); + await this.adapter.send({ + to: input.to, + subject: input.subject, + html, + }); - if (!result.success) { + this.logger.log(`Email sent to ${input.to}`); + } catch (error) { this.logger.error( - `Email failed → ${payload.to}`, - result.error, - ); - } else { - this.logger.log( - `Email sent → ${payload.to} (${result.provider})`, + `Failed to send email to ${input.to}`, + error.stack, ); + throw error; } - - return result; - } - - private renderTemplate(templateName: string, data: Record) { - const templatePath = path.join( - __dirname, - 'templates', - `${templateName}.hbs`, - ); - - const source = fs.readFileSync(templatePath, 'utf-8'); - const template = Handlebars.compile(source); - - return template(data); } - - async sendEmail(payload: SendEmailPayload) { - EmailLogger.logSendAttempt(payload); - EmailLogger.logTemplateRender(payload.template); - - const html = this.renderTemplate(payload.template, payload.data); - - const result = await this.adapter.send({ - ...payload, - data: { html }, - }); - - if (result.success) { - EmailLogger.logSuccess(payload, result); - } else { - EmailLogger.logFailure(payload, result); - } - - return result; -} } diff --git a/apps/api/src/notifications/email/email.types.ts b/apps/api/src/notifications/email/email.types.ts index 1c154f2..1b9eaa3 100644 --- a/apps/api/src/notifications/email/email.types.ts +++ b/apps/api/src/notifications/email/email.types.ts @@ -1,13 +1,14 @@ -export interface SendEmailPayload { +export interface SendEmailInput { to: string; subject: string; template: string; data: Record; } -export interface EmailSendResult { - success: boolean; - provider: string; - messageId?: string; - error?: any; +export interface EmailProvider { + send(options: { + to: string; + subject: string; + html: string; + }): Promise; } diff --git a/apps/api/src/notifications/email/providers/mock.provider.ts b/apps/api/src/notifications/email/providers/mock.provider.ts new file mode 100644 index 0000000..1d5b602 --- /dev/null +++ b/apps/api/src/notifications/email/providers/mock.provider.ts @@ -0,0 +1,14 @@ +import { EmailAdapter } from '../adapters/email.adapter'; +import { Logger } from '@nestjs/common'; + +export class MockEmailProvider implements EmailAdapter { + private readonly logger = new Logger(MockEmailProvider.name); + + async send({ to, subject, html }: any): Promise { + this.logger.log(`[MOCK EMAIL] + To: ${to} + Subject: ${subject} + Body: ${html} + `); + } +} diff --git a/apps/api/src/notifications/email/providers/sendgrid.provider.ts b/apps/api/src/notifications/email/providers/sendgrid.provider.ts new file mode 100644 index 0000000..dc5d28e --- /dev/null +++ b/apps/api/src/notifications/email/providers/sendgrid.provider.ts @@ -0,0 +1,7 @@ +import { EmailAdapter } from "../adapters/email.adapter"; + +export class SendGridEmailProvider implements EmailAdapter { + async send({ to, subject, html }: any): Promise { + // sendgrid.send({ to, subject, html }) + } +} diff --git a/apps/api/src/notifications/email/templates/booking-cancelled.hbs b/apps/api/src/notifications/email/templates/booking-cancelled.hbs new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/src/notifications/email/templates/booking-confirmed.hbs b/apps/api/src/notifications/email/templates/booking-confirmed.hbs index 4ce00b3..2813282 100644 --- a/apps/api/src/notifications/email/templates/booking-confirmed.hbs +++ b/apps/api/src/notifications/email/templates/booking-confirmed.hbs @@ -1,9 +1,9 @@

Booking Confirmed 🎉

-

Hello {{userName}},

+

Hello {{name}},

-

Your booking for {{serviceName}} has been confirmed.

+

Your session with {{mentorName}} has been confirmed.

-Date: {{date}}
-Reference: {{reference}} + Date: {{date}}
+ Time: {{time}}

diff --git a/apps/api/src/notifications/email/templates/payment-failed.hbs b/apps/api/src/notifications/email/templates/payment-failed.hbs new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/src/notifications/email/templates/payment-success.hbs b/apps/api/src/notifications/email/templates/payment-success.hbs index 8c4356c..944aeb4 100644 --- a/apps/api/src/notifications/email/templates/payment-success.hbs +++ b/apps/api/src/notifications/email/templates/payment-success.hbs @@ -1,9 +1,7 @@

Payment Successful ✅

-

Hi {{userName}},

+

Hi {{name}},

-

-Your payment of {{amount}} was successful. -

+

Your payment of {{amount}} was successful.

Transaction ID: {{transactionId}}

diff --git a/apps/api/src/notifications/email/templates/template.service.ts b/apps/api/src/notifications/email/templates/template.service.ts new file mode 100644 index 0000000..40c58ff --- /dev/null +++ b/apps/api/src/notifications/email/templates/template.service.ts @@ -0,0 +1,20 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as Handlebars from 'handlebars'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class EmailTemplateService { + render(templateName: string, data: Record): string { + const filePath = path.join( + __dirname, + 'templates', + `${templateName}.hbs`, + ); + + const source = fs.readFileSync(filePath, 'utf8'); + const template = Handlebars.compile(source); + + return template(data); + } +} diff --git a/apps/api/src/reviews/dto/create-review.dto.ts b/apps/api/src/reviews/dto/create-review.dto.ts new file mode 100644 index 0000000..1f5e0da --- /dev/null +++ b/apps/api/src/reviews/dto/create-review.dto.ts @@ -0,0 +1,15 @@ +import { IsInt, IsOptional, IsString, Max, Min, IsUUID } from 'class-validator'; + +export class CreateReviewDto { + @IsUUID() + sessionId: string; + + @IsInt() + @Min(1) + @Max(5) + rating: number; + + @IsOptional() + @IsString() + comment?: string; +} diff --git a/apps/api/src/reviews/entities/review.entity.ts b/apps/api/src/reviews/entities/review.entity.ts new file mode 100644 index 0000000..efe35ca --- /dev/null +++ b/apps/api/src/reviews/entities/review.entity.ts @@ -0,0 +1,41 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + Index, + Unique, +} from 'typeorm'; +import { User } from '@/users/entities/user.entity'; +import { Session } from '@/sessions/entities/session.entity'; + + + +@Entity('reviews') +@Unique(['session', 'reviewer']) +@Index(['session', 'reviewer']) +export class Review { + @PrimaryGeneratedColumn('uuid') + id: string; + + /** Mentee */ + @ManyToOne(() => User, { eager: false }) + reviewer: User; + + /** Mentor */ + @ManyToOne(() => User, { eager: false }) + reviewee: User; + + @ManyToOne(() => Session, { eager: false }) + session: Session; + + @Column({ type: 'int' }) + rating: number; // 1–5 + + @Column({ type: 'text', nullable: true }) + comment?: string; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/apps/api/src/reviews/reviews.controller.ts b/apps/api/src/reviews/reviews.controller.ts new file mode 100644 index 0000000..7dee6f9 --- /dev/null +++ b/apps/api/src/reviews/reviews.controller.ts @@ -0,0 +1,29 @@ +import { + Body, + Controller, + Post, + UseGuards, +} from '@nestjs/common'; + +import { ReviewsService } from './reviews.service'; +import { CreateReviewDto } from './dto/create-review.dto'; + +// import { AuthGuard } from '../auth/guards/auth.guard'; +import { CurrentUser } from '../decorators/auth/current-user.decorator'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; + +@UseGuards(JwtAuthGuard) +@Controller('reviews') +export class ReviewsController { + constructor( + private readonly reviewsService: ReviewsService, + ) {} + + @Post() + async create( + @CurrentUser('id') userId: string, + @Body() dto: CreateReviewDto, + ) { + return this.reviewsService.createReview(userId, dto); + } +} diff --git a/apps/api/src/reviews/reviews.module.ts b/apps/api/src/reviews/reviews.module.ts new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/src/reviews/reviews.service.spec.ts b/apps/api/src/reviews/reviews.service.spec.ts new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/src/reviews/reviews.service.ts b/apps/api/src/reviews/reviews.service.ts new file mode 100644 index 0000000..bdf46ce --- /dev/null +++ b/apps/api/src/reviews/reviews.service.ts @@ -0,0 +1,102 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; + +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { Review } from './entities/review.entity'; +import { Session } from '../sessions/entities/session.entity'; +import { User } from '../users/entities/user.entity'; + +import { CreateReviewDto } from './dto/create-review.dto'; + +@Injectable() +export class ReviewsService { + constructor( + @InjectRepository(Review) + private readonly reviewRepo: Repository, + + @InjectRepository(Session) + private readonly sessionRepo: Repository, + + @InjectRepository(User) + private readonly userRepo: Repository, + ) {} + + async createReview( + reviewerId: string, + dto: CreateReviewDto, + ): Promise { + const session = await this.sessionRepo.findOne({ + where: { id: dto.sessionId }, + relations: ['mentee', 'mentor'], + }); + + if (!session) { + throw new NotFoundException('Session not found'); + } + + // Rule 1: Session must be completed + if (session.status !== 'COMPLETED') { + throw new BadRequestException( + 'Reviews are only allowed after session completion', + ); + } + + // Rule 2: Reviewer must be the mentee + if (session.mentee.id !== reviewerId) { + throw new ForbiddenException( + 'Only the session mentee can submit a review', + ); + } + + // Rule 3: Reviewee must be the mentor + const mentor = session.mentor; + + // Rule 4: Prevent duplicate reviews + const existing = await this.reviewRepo.findOne({ + where: { + session: { id: session.id }, + reviewer: { id: reviewerId }, + }, + }); + + if (existing) { + throw new ConflictException( + 'You have already reviewed this session', + ); + } + + const review = this.reviewRepo.create({ + session, + reviewer: { id: reviewerId } as User, + reviewee: mentor, + rating: dto.rating, + comment: dto.comment, + }); + + const saved = await this.reviewRepo.save(review); + + // Optional hook: update mentor rating + await this.updateMentorRating(mentor.id); + + return saved; + } + + private async updateMentorRating(mentorId: string): Promise { + const { avg } = await this.reviewRepo + .createQueryBuilder('review') + .select('AVG(review.rating)', 'avg') + .where('review.revieweeId = :mentorId', { mentorId }) + .getRawOne<{ avg: string }>(); + + await this.userRepo.update(mentorId, { + averageRating: avg ? Number(avg) : 0, + }); + } +}