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,
+ });
+ }
+}