diff --git a/apps/api/package.json b/apps/api/package.json index d22c361..0162816 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -26,8 +26,11 @@ "bcrypt": "^6.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", + "firebase-admin": "^13.7.0", + "handlebars": "^4.7.9", "helmet": "^8.1.0", "ioredis": "^5.4.0", + "nodemailer": "^8.0.5", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", @@ -35,7 +38,8 @@ "pino-pretty": "^13.0.0", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.0", - "sanitize-html": "^2.17.2" + "sanitize-html": "^2.17.2", + "typesense": "^3.0.5" }, "devDependencies": { "@nestjs/cli": "^11.0.0", @@ -44,6 +48,7 @@ "@types/bcrypt": "^6.0.0", "@types/express": "^5.0.0", "@types/node": "^22.0.0", + "@types/nodemailer": "^8.0.0", "@types/passport-jwt": "^4.0.1", "@types/passport-local": "^1.0.38", "@types/sanitize-html": "^2.16.1", diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index a44bff0..8b667bb 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -1,5 +1,6 @@ import { SharedModule } from '@modules/shared'; import { AuthModule } from '@modules/auth'; +import { NotificationsModule } from '@modules/notifications'; import { Module } from '@nestjs/common'; import { APP_GUARD } from '@nestjs/core'; import { CqrsModule } from '@nestjs/cqrs'; @@ -12,6 +13,7 @@ import { AppController } from './app.controller'; CqrsModule.forRoot(), SharedModule, AuthModule, + NotificationsModule, // ── Rate Limiting ── // Default: 60 requests per 60 seconds per IP diff --git a/apps/api/src/modules/notifications/application/commands/send-notification/send-notification.command.ts b/apps/api/src/modules/notifications/application/commands/send-notification/send-notification.command.ts new file mode 100644 index 0000000..7bd70a5 --- /dev/null +++ b/apps/api/src/modules/notifications/application/commands/send-notification/send-notification.command.ts @@ -0,0 +1,11 @@ +import { type NotificationChannel } from '../../../domain/value-objects/notification-channel.vo'; + +export class SendNotificationCommand { + constructor( + public readonly userId: string, + public readonly channel: NotificationChannel, + public readonly templateKey: string, + public readonly templateData: Record, + public readonly recipientAddress: string, + ) {} +} diff --git a/apps/api/src/modules/notifications/application/commands/send-notification/send-notification.handler.ts b/apps/api/src/modules/notifications/application/commands/send-notification/send-notification.handler.ts new file mode 100644 index 0000000..d4207a2 --- /dev/null +++ b/apps/api/src/modules/notifications/application/commands/send-notification/send-notification.handler.ts @@ -0,0 +1,99 @@ +import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; +import { Inject } from '@nestjs/common'; +import { LoggerService } from '@modules/shared/infrastructure/logger.service'; +import { EventBusService } from '@modules/shared/infrastructure/event-bus.service'; +import { SendNotificationCommand } from './send-notification.command'; +import { + NOTIFICATION_REPOSITORY, + type INotificationRepository, +} from '../../../domain/repositories/notification.repository'; +import { + NOTIFICATION_PREFERENCE_REPOSITORY, + type INotificationPreferenceRepository, +} from '../../../domain/repositories/notification-preference.repository'; +import { NotificationSentEvent } from '../../../domain/events/notification-sent.event'; +import { EmailService } from '../../../infrastructure/services/email.service'; +import { FcmService } from '../../../infrastructure/services/fcm.service'; +import { TemplateService } from '../../../infrastructure/services/template.service'; + +@CommandHandler(SendNotificationCommand) +export class SendNotificationHandler implements ICommandHandler { + constructor( + @Inject(NOTIFICATION_REPOSITORY) + private readonly notificationRepo: INotificationRepository, + @Inject(NOTIFICATION_PREFERENCE_REPOSITORY) + private readonly preferenceRepo: INotificationPreferenceRepository, + private readonly emailService: EmailService, + private readonly fcmService: FcmService, + private readonly templateService: TemplateService, + private readonly eventBus: EventBusService, + private readonly logger: LoggerService, + ) {} + + async execute(command: SendNotificationCommand): Promise { + const { userId, channel, templateKey, templateData, recipientAddress } = command; + + // Check user preference + const isEnabled = await this.preferenceRepo.isEnabled(userId, channel, templateKey); + if (!isEnabled) { + this.logger.log( + `Notification skipped: user ${userId} disabled ${channel}/${templateKey}`, + 'SendNotificationHandler', + ); + return; + } + + // Render template + const rendered = this.templateService.render(templateKey, templateData); + + // Persist notification log + const notification = await this.notificationRepo.create({ + userId, + channel, + templateKey, + subject: rendered.subject, + body: rendered.body, + metadata: templateData, + }); + + try { + switch (channel) { + case 'EMAIL': + await this.emailService.send({ + to: recipientAddress, + subject: rendered.subject, + html: rendered.body, + }); + break; + + case 'PUSH': + await this.fcmService.send({ + token: recipientAddress, + title: rendered.subject, + body: rendered.body.replace(/<[^>]*>/g, ''), // Strip HTML for push + }); + break; + + case 'SMS': + case 'ZALO_OA': + // Placeholder — these channels will be implemented when providers are integrated + this.logger.warn( + `Channel ${channel} not yet implemented — notification logged but not sent`, + 'SendNotificationHandler', + ); + await this.notificationRepo.updateStatus(notification.id, 'PENDING'); + return; + } + + await this.notificationRepo.updateStatus(notification.id, 'SENT'); + this.eventBus.publish(new NotificationSentEvent(notification.id, userId, channel, templateKey)); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + await this.notificationRepo.updateStatus(notification.id, 'FAILED', errorMsg); + this.logger.error( + `Notification ${notification.id} failed on ${channel}: ${errorMsg}`, + 'SendNotificationHandler', + ); + } + } +} diff --git a/apps/api/src/modules/notifications/application/listeners/agent-verified.listener.ts b/apps/api/src/modules/notifications/application/listeners/agent-verified.listener.ts new file mode 100644 index 0000000..8fcaa31 --- /dev/null +++ b/apps/api/src/modules/notifications/application/listeners/agent-verified.listener.ts @@ -0,0 +1,38 @@ +import { Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { CommandBus } from '@nestjs/cqrs'; +import { PrismaService } from '@modules/shared/infrastructure/prisma.service'; +import { LoggerService } from '@modules/shared/infrastructure/logger.service'; +import { AgentVerifiedEvent } from '@modules/auth/domain/events/agent-verified.event'; +import { SendNotificationCommand } from '../commands/send-notification/send-notification.command'; + +@Injectable() +export class AgentVerifiedListener { + constructor( + private readonly commandBus: CommandBus, + private readonly prisma: PrismaService, + private readonly logger: LoggerService, + ) {} + + @OnEvent('agent.verified', { async: true }) + async handle(event: AgentVerifiedEvent): Promise { + this.logger.log(`Handling agent.verified for ${event.aggregateId}`, 'AgentVerifiedListener'); + + const agent = await this.prisma.agent.findUnique({ + where: { id: event.aggregateId }, + include: { user: { select: { id: true, email: true } } }, + }); + + if (!agent?.user.email) return; + + await this.commandBus.execute( + new SendNotificationCommand( + agent.user.id, + 'EMAIL', + 'agent.verified', + { agentId: event.aggregateId }, + agent.user.email, + ), + ); + } +} diff --git a/apps/api/src/modules/notifications/application/listeners/user-registered.listener.ts b/apps/api/src/modules/notifications/application/listeners/user-registered.listener.ts new file mode 100644 index 0000000..cb510db --- /dev/null +++ b/apps/api/src/modules/notifications/application/listeners/user-registered.listener.ts @@ -0,0 +1,41 @@ +import { Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { CommandBus } from '@nestjs/cqrs'; +import { PrismaService } from '@modules/shared/infrastructure/prisma.service'; +import { LoggerService } from '@modules/shared/infrastructure/logger.service'; +import { UserRegisteredEvent } from '@modules/auth/domain/events/user-registered.event'; +import { SendNotificationCommand } from '../commands/send-notification/send-notification.command'; + +@Injectable() +export class UserRegisteredListener { + constructor( + private readonly commandBus: CommandBus, + private readonly prisma: PrismaService, + private readonly logger: LoggerService, + ) {} + + @OnEvent('user.registered', { async: true }) + async handle(event: UserRegisteredEvent): Promise { + this.logger.log(`Handling user.registered for ${event.aggregateId}`, 'UserRegisteredListener'); + + const user = await this.prisma.user.findUnique({ + where: { id: event.aggregateId }, + select: { email: true, phone: true }, + }); + + if (!user) return; + + // Send welcome email if user has email + if (user.email) { + await this.commandBus.execute( + new SendNotificationCommand( + event.aggregateId, + 'EMAIL', + 'user.registered', + { phone: event.phone, role: event.role }, + user.email, + ), + ); + } + } +} diff --git a/apps/api/src/modules/notifications/domain/entities/notification-preference.entity.ts b/apps/api/src/modules/notifications/domain/entities/notification-preference.entity.ts new file mode 100644 index 0000000..856c600 --- /dev/null +++ b/apps/api/src/modules/notifications/domain/entities/notification-preference.entity.ts @@ -0,0 +1,11 @@ +import { type NotificationChannel } from '../value-objects/notification-channel.vo'; + +export interface NotificationPreferenceEntity { + id: string; + userId: string; + channel: NotificationChannel; + eventType: string; + enabled: boolean; + createdAt: Date; + updatedAt: Date; +} diff --git a/apps/api/src/modules/notifications/domain/entities/notification.entity.ts b/apps/api/src/modules/notifications/domain/entities/notification.entity.ts new file mode 100644 index 0000000..3375562 --- /dev/null +++ b/apps/api/src/modules/notifications/domain/entities/notification.entity.ts @@ -0,0 +1,17 @@ +import { type NotificationChannel } from '../value-objects/notification-channel.vo'; + +export type NotificationStatus = 'PENDING' | 'SENT' | 'FAILED' | 'DELIVERED'; + +export interface NotificationEntity { + id: string; + userId: string; + channel: NotificationChannel; + templateKey: string; + subject: string | null; + body: string; + metadata: Record | null; + status: NotificationStatus; + errorDetail: string | null; + sentAt: Date | null; + createdAt: Date; +} diff --git a/apps/api/src/modules/notifications/domain/events/notification-sent.event.ts b/apps/api/src/modules/notifications/domain/events/notification-sent.event.ts new file mode 100644 index 0000000..0ff4b3c --- /dev/null +++ b/apps/api/src/modules/notifications/domain/events/notification-sent.event.ts @@ -0,0 +1,14 @@ +import { type DomainEvent } from '@modules/shared/domain/domain-event'; +import { type NotificationChannel } from '../value-objects/notification-channel.vo'; + +export class NotificationSentEvent implements DomainEvent { + readonly eventName = 'notification.sent'; + readonly occurredAt = new Date(); + + constructor( + public readonly aggregateId: string, + public readonly userId: string, + public readonly channel: NotificationChannel, + public readonly templateKey: string, + ) {} +} diff --git a/apps/api/src/modules/notifications/domain/index.ts b/apps/api/src/modules/notifications/domain/index.ts new file mode 100644 index 0000000..bba5c73 --- /dev/null +++ b/apps/api/src/modules/notifications/domain/index.ts @@ -0,0 +1,16 @@ +export type { NotificationEntity, NotificationStatus } from './entities/notification.entity'; +export type { NotificationPreferenceEntity } from './entities/notification-preference.entity'; +export { NotificationSentEvent } from './events/notification-sent.event'; +export { + NOTIFICATION_REPOSITORY, + type INotificationRepository, + type CreateNotificationDto, +} from './repositories/notification.repository'; +export { + NOTIFICATION_PREFERENCE_REPOSITORY, + type INotificationPreferenceRepository, +} from './repositories/notification-preference.repository'; +export { + NotificationChannel, + ALL_CHANNELS, +} from './value-objects/notification-channel.vo'; diff --git a/apps/api/src/modules/notifications/domain/repositories/notification-preference.repository.ts b/apps/api/src/modules/notifications/domain/repositories/notification-preference.repository.ts new file mode 100644 index 0000000..00d8b90 --- /dev/null +++ b/apps/api/src/modules/notifications/domain/repositories/notification-preference.repository.ts @@ -0,0 +1,15 @@ +import { type NotificationPreferenceEntity } from '../entities/notification-preference.entity'; +import { type NotificationChannel } from '../value-objects/notification-channel.vo'; + +export const NOTIFICATION_PREFERENCE_REPOSITORY = Symbol('NOTIFICATION_PREFERENCE_REPOSITORY'); + +export interface INotificationPreferenceRepository { + findByUserId(userId: string): Promise; + isEnabled(userId: string, channel: NotificationChannel, eventType: string): Promise; + upsert( + userId: string, + channel: NotificationChannel, + eventType: string, + enabled: boolean, + ): Promise; +} diff --git a/apps/api/src/modules/notifications/domain/repositories/notification.repository.ts b/apps/api/src/modules/notifications/domain/repositories/notification.repository.ts new file mode 100644 index 0000000..714e56b --- /dev/null +++ b/apps/api/src/modules/notifications/domain/repositories/notification.repository.ts @@ -0,0 +1,19 @@ +import { type NotificationEntity, type NotificationStatus } from '../entities/notification.entity'; +import { type NotificationChannel } from '../value-objects/notification-channel.vo'; + +export const NOTIFICATION_REPOSITORY = Symbol('NOTIFICATION_REPOSITORY'); + +export interface CreateNotificationDto { + userId: string; + channel: NotificationChannel; + templateKey: string; + subject: string | null; + body: string; + metadata?: Record; +} + +export interface INotificationRepository { + create(dto: CreateNotificationDto): Promise; + updateStatus(id: string, status: NotificationStatus, errorDetail?: string): Promise; + findByUserId(userId: string, limit?: number): Promise; +} diff --git a/apps/api/src/modules/notifications/domain/value-objects/notification-channel.vo.ts b/apps/api/src/modules/notifications/domain/value-objects/notification-channel.vo.ts new file mode 100644 index 0000000..d69a356 --- /dev/null +++ b/apps/api/src/modules/notifications/domain/value-objects/notification-channel.vo.ts @@ -0,0 +1,17 @@ +import { NotificationChannel as PrismaChannel } from '@prisma/client'; + +export const NotificationChannel = { + EMAIL: 'EMAIL', + SMS: 'SMS', + PUSH: 'PUSH', + ZALO_OA: 'ZALO_OA', +} as const; + +export type NotificationChannel = PrismaChannel; + +export const ALL_CHANNELS: NotificationChannel[] = [ + NotificationChannel.EMAIL, + NotificationChannel.SMS, + NotificationChannel.PUSH, + NotificationChannel.ZALO_OA, +]; diff --git a/apps/api/src/modules/notifications/index.ts b/apps/api/src/modules/notifications/index.ts new file mode 100644 index 0000000..98446ed --- /dev/null +++ b/apps/api/src/modules/notifications/index.ts @@ -0,0 +1 @@ +export { NotificationsModule } from './notifications.module'; diff --git a/apps/api/src/modules/notifications/infrastructure/index.ts b/apps/api/src/modules/notifications/infrastructure/index.ts new file mode 100644 index 0000000..46d8e8f --- /dev/null +++ b/apps/api/src/modules/notifications/infrastructure/index.ts @@ -0,0 +1,5 @@ +export { PrismaNotificationRepository } from './repositories/prisma-notification.repository'; +export { PrismaNotificationPreferenceRepository } from './repositories/prisma-notification-preference.repository'; +export { EmailService, type SendEmailDto } from './services/email.service'; +export { FcmService, type SendPushDto } from './services/fcm.service'; +export { TemplateService, type RenderedTemplate, type TemplateDefinition } from './services/template.service'; diff --git a/apps/api/src/modules/notifications/infrastructure/repositories/prisma-notification-preference.repository.ts b/apps/api/src/modules/notifications/infrastructure/repositories/prisma-notification-preference.repository.ts new file mode 100644 index 0000000..66beff4 --- /dev/null +++ b/apps/api/src/modules/notifications/infrastructure/repositories/prisma-notification-preference.repository.ts @@ -0,0 +1,39 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '@modules/shared/infrastructure/prisma.service'; +import { type INotificationPreferenceRepository } from '../../domain/repositories/notification-preference.repository'; +import { type NotificationPreferenceEntity } from '../../domain/entities/notification-preference.entity'; +import { type NotificationChannel } from '../../domain/value-objects/notification-channel.vo'; + +@Injectable() +export class PrismaNotificationPreferenceRepository implements INotificationPreferenceRepository { + constructor(private readonly prisma: PrismaService) {} + + async findByUserId(userId: string): Promise { + const records = await this.prisma.notificationPreference.findMany({ + where: { userId }, + }); + return records as NotificationPreferenceEntity[]; + } + + async isEnabled(userId: string, channel: NotificationChannel, eventType: string): Promise { + const pref = await this.prisma.notificationPreference.findUnique({ + where: { userId_channel_eventType: { userId, channel, eventType } }, + }); + // Default to enabled if no preference record exists + return pref?.enabled ?? true; + } + + async upsert( + userId: string, + channel: NotificationChannel, + eventType: string, + enabled: boolean, + ): Promise { + const record = await this.prisma.notificationPreference.upsert({ + where: { userId_channel_eventType: { userId, channel, eventType } }, + create: { userId, channel, eventType, enabled }, + update: { enabled }, + }); + return record as NotificationPreferenceEntity; + } +} diff --git a/apps/api/src/modules/notifications/infrastructure/repositories/prisma-notification.repository.ts b/apps/api/src/modules/notifications/infrastructure/repositories/prisma-notification.repository.ts new file mode 100644 index 0000000..0e61d74 --- /dev/null +++ b/apps/api/src/modules/notifications/infrastructure/repositories/prisma-notification.repository.ts @@ -0,0 +1,76 @@ +import { Injectable } from '@nestjs/common'; +import { type Prisma } from '@prisma/client'; +import { PrismaService } from '@modules/shared/infrastructure/prisma.service'; +import { + type INotificationRepository, + type CreateNotificationDto, +} from '../../domain/repositories/notification.repository'; +import { type NotificationEntity, type NotificationStatus } from '../../domain/entities/notification.entity'; + +@Injectable() +export class PrismaNotificationRepository implements INotificationRepository { + constructor(private readonly prisma: PrismaService) {} + + async create(dto: CreateNotificationDto): Promise { + const record = await this.prisma.notificationLog.create({ + data: { + userId: dto.userId, + channel: dto.channel, + templateKey: dto.templateKey, + subject: dto.subject, + body: dto.body, + metadata: (dto.metadata ?? undefined) as Prisma.InputJsonValue | undefined, + status: 'PENDING', + }, + }); + return this.toEntity(record); + } + + async updateStatus(id: string, status: NotificationStatus, errorDetail?: string): Promise { + await this.prisma.notificationLog.update({ + where: { id }, + data: { + status, + errorDetail: errorDetail ?? null, + sentAt: status === 'SENT' || status === 'DELIVERED' ? new Date() : undefined, + }, + }); + } + + async findByUserId(userId: string, limit = 50): Promise { + const records = await this.prisma.notificationLog.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + take: limit, + }); + return records.map((r) => this.toEntity(r)); + } + + private toEntity(record: { + id: string; + userId: string; + channel: string; + templateKey: string; + subject: string | null; + body: string; + metadata: unknown; + status: string; + errorDetail: string | null; + sentAt: Date | null; + createdAt: Date; + }): NotificationEntity { + return { + id: record.id, + userId: record.userId, + channel: record.channel as NotificationEntity['channel'], + templateKey: record.templateKey, + subject: record.subject, + body: record.body, + metadata: record.metadata as Record | null, + status: record.status as NotificationStatus, + errorDetail: record.errorDetail, + sentAt: record.sentAt, + createdAt: record.createdAt, + }; + } +} diff --git a/apps/api/src/modules/notifications/infrastructure/services/email.service.ts b/apps/api/src/modules/notifications/infrastructure/services/email.service.ts new file mode 100644 index 0000000..49834e6 --- /dev/null +++ b/apps/api/src/modules/notifications/infrastructure/services/email.service.ts @@ -0,0 +1,63 @@ +import { Injectable, type OnModuleInit } from '@nestjs/common'; +import { LoggerService } from '@modules/shared/infrastructure/logger.service'; +import * as nodemailer from 'nodemailer'; + +export interface SendEmailDto { + to: string; + subject: string; + html: string; +} + +@Injectable() +export class EmailService implements OnModuleInit { + private transporter!: nodemailer.Transporter; + + constructor(private readonly logger: LoggerService) {} + + onModuleInit(): void { + const host = process.env['SMTP_HOST'] ?? 'localhost'; + const port = Number(process.env['SMTP_PORT'] ?? '1025'); + const user = process.env['SMTP_USER']; + const pass = process.env['SMTP_PASS']; + + this.transporter = nodemailer.createTransport({ + host, + port, + secure: port === 465, + ...(user && pass ? { auth: { user, pass } } : {}), + }); + + this.logger.log(`Email transport configured: ${host}:${port}`, 'EmailService'); + } + + async send(dto: SendEmailDto): Promise<{ messageId: string }> { + const from = process.env['SMTP_FROM'] ?? 'noreply@goodgo.vn'; + + try { + const info = await this.transporter.sendMail({ + from, + to: dto.to, + subject: dto.subject, + html: dto.html, + }); + + this.logger.log(`Email sent to ${dto.to}: ${info.messageId}`, 'EmailService'); + return { messageId: info.messageId }; + } catch (error) { + this.logger.error( + `Failed to send email to ${dto.to}: ${error instanceof Error ? error.message : String(error)}`, + 'EmailService', + ); + throw error; + } + } + + async verify(): Promise { + try { + await this.transporter.verify(); + return true; + } catch { + return false; + } + } +} diff --git a/apps/api/src/modules/notifications/infrastructure/services/fcm.service.ts b/apps/api/src/modules/notifications/infrastructure/services/fcm.service.ts new file mode 100644 index 0000000..6673dd3 --- /dev/null +++ b/apps/api/src/modules/notifications/infrastructure/services/fcm.service.ts @@ -0,0 +1,74 @@ +import { Injectable, type OnModuleInit } from '@nestjs/common'; +import { LoggerService } from '@modules/shared/infrastructure/logger.service'; +import * as admin from 'firebase-admin'; + +export interface SendPushDto { + token: string; + title: string; + body: string; + data?: Record; +} + +@Injectable() +export class FcmService implements OnModuleInit { + private initialized = false; + + constructor(private readonly logger: LoggerService) {} + + onModuleInit(): void { + const serviceAccountJson = process.env['FIREBASE_SERVICE_ACCOUNT']; + if (!serviceAccountJson) { + this.logger.warn( + 'FIREBASE_SERVICE_ACCOUNT not set — push notifications disabled', + 'FcmService', + ); + return; + } + + try { + const serviceAccount = JSON.parse(serviceAccountJson) as admin.ServiceAccount; + if (!admin.apps.length) { + admin.initializeApp({ credential: admin.credential.cert(serviceAccount) }); + } + this.initialized = true; + this.logger.log('Firebase Admin initialized for push notifications', 'FcmService'); + } catch (error) { + this.logger.error( + `Failed to initialize Firebase: ${error instanceof Error ? error.message : String(error)}`, + 'FcmService', + ); + } + } + + get isAvailable(): boolean { + return this.initialized; + } + + async send(dto: SendPushDto): Promise { + if (!this.initialized) { + throw new Error('FCM not initialized — FIREBASE_SERVICE_ACCOUNT not configured'); + } + + try { + const messageId = await admin.messaging().send({ + token: dto.token, + notification: { + title: dto.title, + body: dto.body, + }, + data: dto.data, + android: { priority: 'high' }, + apns: { payload: { aps: { sound: 'default' } } }, + }); + + this.logger.log(`Push sent to token ${dto.token.slice(0, 10)}...: ${messageId}`, 'FcmService'); + return messageId; + } catch (error) { + this.logger.error( + `Failed to send push: ${error instanceof Error ? error.message : String(error)}`, + 'FcmService', + ); + throw error; + } + } +} diff --git a/apps/api/src/modules/notifications/infrastructure/services/template.service.ts b/apps/api/src/modules/notifications/infrastructure/services/template.service.ts new file mode 100644 index 0000000..08ea5f1 --- /dev/null +++ b/apps/api/src/modules/notifications/infrastructure/services/template.service.ts @@ -0,0 +1,83 @@ +import { Injectable } from '@nestjs/common'; +import Handlebars from 'handlebars'; + +export interface TemplateDefinition { + subject: string; + body: string; +} + +export interface RenderedTemplate { + subject: string; + body: string; +} + +const TEMPLATES: Record = { + 'user.registered': { + subject: 'Chào mừng bạn đến với GoodGo!', + body: `

Xin chào {{phone}}!

+

Cảm ơn bạn đã đăng ký tài khoản GoodGo với vai trò {{role}}.

+

Bạn có thể bắt đầu khám phá bất động sản ngay bây giờ.

+

Trân trọng,
Đội ngũ GoodGo

`, + }, + 'agent.verified': { + subject: 'Tài khoản Đại lý đã được xác minh', + body: `

Chúc mừng!

+

Tài khoản đại lý của bạn (ID: {{agentId}}) đã được xác minh thành công.

+

Bạn có thể bắt đầu đăng tin bất động sản ngay bây giờ.

+

Trân trọng,
Đội ngũ GoodGo

`, + }, + 'listing.approved': { + subject: 'Tin đăng đã được duyệt', + body: `

Tin đăng được phê duyệt!

+

Tin đăng {{listingTitle}} của bạn đã được duyệt và hiển thị trên GoodGo.

+

Trân trọng,
Đội ngũ GoodGo

`, + }, + 'inquiry.received': { + subject: 'Bạn có yêu cầu tư vấn mới', + body: `

Yêu cầu tư vấn mới

+

Bạn nhận được yêu cầu tư vấn từ {{senderName}} cho tin đăng {{listingTitle}}.

+

Nội dung: {{message}}

+

Trân trọng,
Đội ngũ GoodGo

`, + }, + 'password.reset': { + subject: 'Đặt lại mật khẩu GoodGo', + body: `

Đặt lại mật khẩu

+

Bạn đã yêu cầu đặt lại mật khẩu. Sử dụng mã OTP sau: {{otp}}

+

Mã có hiệu lực trong {{expiryMinutes}} phút.

+

Nếu bạn không yêu cầu, hãy bỏ qua email này.

+

Trân trọng,
Đội ngũ GoodGo

`, + }, +}; + +@Injectable() +export class TemplateService { + private compiledTemplates = new Map(); + + constructor() { + for (const [key, def] of Object.entries(TEMPLATES)) { + this.compiledTemplates.set(key, { + subject: Handlebars.compile(def.subject), + body: Handlebars.compile(def.body), + }); + } + } + + render(templateKey: string, data: Record): RenderedTemplate { + const compiled = this.compiledTemplates.get(templateKey); + if (!compiled) { + throw new Error(`Notification template "${templateKey}" not found`); + } + return { + subject: compiled.subject(data), + body: compiled.body(data), + }; + } + + hasTemplate(templateKey: string): boolean { + return this.compiledTemplates.has(templateKey); + } + + getTemplateKeys(): string[] { + return Array.from(this.compiledTemplates.keys()); + } +} diff --git a/apps/api/src/modules/notifications/notifications.module.ts b/apps/api/src/modules/notifications/notifications.module.ts new file mode 100644 index 0000000..5ba7dfd --- /dev/null +++ b/apps/api/src/modules/notifications/notifications.module.ts @@ -0,0 +1,48 @@ +import { Module } from '@nestjs/common'; +import { CqrsModule } from '@nestjs/cqrs'; + +// Domain +import { NOTIFICATION_REPOSITORY } from './domain/repositories/notification.repository'; +import { NOTIFICATION_PREFERENCE_REPOSITORY } from './domain/repositories/notification-preference.repository'; + +// Infrastructure +import { PrismaNotificationRepository } from './infrastructure/repositories/prisma-notification.repository'; +import { PrismaNotificationPreferenceRepository } from './infrastructure/repositories/prisma-notification-preference.repository'; +import { EmailService } from './infrastructure/services/email.service'; +import { FcmService } from './infrastructure/services/fcm.service'; +import { TemplateService } from './infrastructure/services/template.service'; + +// Application +import { SendNotificationHandler } from './application/commands/send-notification/send-notification.handler'; +import { UserRegisteredListener } from './application/listeners/user-registered.listener'; +import { AgentVerifiedListener } from './application/listeners/agent-verified.listener'; + +// Presentation +import { NotificationsController } from './presentation/controllers/notifications.controller'; + +const CommandHandlers = [SendNotificationHandler]; + +const EventListeners = [UserRegisteredListener, AgentVerifiedListener]; + +@Module({ + imports: [CqrsModule], + controllers: [NotificationsController], + providers: [ + // Repositories + { provide: NOTIFICATION_REPOSITORY, useClass: PrismaNotificationRepository }, + { provide: NOTIFICATION_PREFERENCE_REPOSITORY, useClass: PrismaNotificationPreferenceRepository }, + + // Services + EmailService, + FcmService, + TemplateService, + + // CQRS + ...CommandHandlers, + + // Event Listeners + ...EventListeners, + ], + exports: [EmailService, FcmService, TemplateService], +}) +export class NotificationsModule {} diff --git a/apps/api/src/modules/notifications/presentation/controllers/notifications.controller.ts b/apps/api/src/modules/notifications/presentation/controllers/notifications.controller.ts new file mode 100644 index 0000000..95965c9 --- /dev/null +++ b/apps/api/src/modules/notifications/presentation/controllers/notifications.controller.ts @@ -0,0 +1,70 @@ +import { + Controller, + Get, + Put, + Body, + Query, + UseGuards, + Inject, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { CurrentUser } from '@modules/auth/presentation/decorators'; +import { type JwtPayload } from '@modules/auth'; +import { + NOTIFICATION_REPOSITORY, + type INotificationRepository, + NOTIFICATION_PREFERENCE_REPOSITORY, + type INotificationPreferenceRepository, +} from '../../domain'; +import { TemplateService } from '../../infrastructure/services/template.service'; +import { IsBoolean, IsEnum, IsString } from 'class-validator'; +import { NotificationChannel as PrismaChannel } from '@prisma/client'; + +class UpdatePreferenceDto { + @IsEnum(PrismaChannel) + channel!: PrismaChannel; + + @IsString() + eventType!: string; + + @IsBoolean() + enabled!: boolean; +} + +@Controller('notifications') +@UseGuards(AuthGuard('jwt')) +export class NotificationsController { + constructor( + @Inject(NOTIFICATION_REPOSITORY) + private readonly notificationRepo: INotificationRepository, + @Inject(NOTIFICATION_PREFERENCE_REPOSITORY) + private readonly preferenceRepo: INotificationPreferenceRepository, + private readonly templateService: TemplateService, + ) {} + + @Get('history') + async getHistory( + @CurrentUser() user: JwtPayload, + @Query('limit') limit?: number, + ) { + return this.notificationRepo.findByUserId(user.sub, limit ?? 50); + } + + @Get('preferences') + async getPreferences(@CurrentUser() user: JwtPayload) { + return this.preferenceRepo.findByUserId(user.sub); + } + + @Put('preferences') + async updatePreference( + @CurrentUser() user: JwtPayload, + @Body() dto: UpdatePreferenceDto, + ) { + return this.preferenceRepo.upsert(user.sub, dto.channel, dto.eventType, dto.enabled); + } + + @Get('templates') + async getTemplates() { + return { templates: this.templateService.getTemplateKeys() }; + } +} diff --git a/apps/web/app/(auth)/layout.tsx b/apps/web/app/(auth)/layout.tsx new file mode 100644 index 0000000..33d8912 --- /dev/null +++ b/apps/web/app/(auth)/layout.tsx @@ -0,0 +1,7 @@ +export default function AuthLayout({ children }: { children: React.ReactNode }) { + return ( +
+
{children}
+
+ ); +} diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx new file mode 100644 index 0000000..2b31e4f --- /dev/null +++ b/apps/web/app/(auth)/login/page.tsx @@ -0,0 +1,123 @@ +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Loader2 } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; +import { OAuthButtons } from '@/components/auth/oauth-buttons'; +import { loginSchema, type LoginFormData } from '@/lib/validations/auth'; +import { useAuthStore } from '@/lib/auth-store'; + +export default function LoginPage() { + const router = useRouter(); + const { login, isLoading, error, clearError } = useAuthStore(); + const [showPassword, setShowPassword] = useState(false); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(loginSchema), + }); + + const onSubmit = async (data: LoginFormData) => { + try { + await login(data); + router.push('/'); + } catch { + // Error is handled by the store + } + }; + + return ( + + + Đăng nhập + Nhập số điện thoại và mật khẩu để đăng nhập + + +
+ {error && ( +
+ {error} + +
+ )} + +
+ + + {errors.phone && ( +

{errors.phone.message}

+ )} +
+ +
+
+ + +
+ + {errors.password && ( +

{errors.password.message}

+ )} +
+ + +
+ +
+
+ +
+
+ Hoặc đăng nhập với +
+
+ + +
+ +

+ Chưa có tài khoản?{' '} + + Đăng ký + +

+
+
+ ); +} diff --git a/apps/web/app/(auth)/register/page.tsx b/apps/web/app/(auth)/register/page.tsx new file mode 100644 index 0000000..9074ada --- /dev/null +++ b/apps/web/app/(auth)/register/page.tsx @@ -0,0 +1,173 @@ +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Loader2 } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; +import { OAuthButtons } from '@/components/auth/oauth-buttons'; +import { registerSchema, type RegisterFormData } from '@/lib/validations/auth'; +import { useAuthStore } from '@/lib/auth-store'; + +export default function RegisterPage() { + const router = useRouter(); + const { register: registerUser, isLoading, error, clearError } = useAuthStore(); + const [showPassword, setShowPassword] = useState(false); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(registerSchema), + }); + + const onSubmit = async (data: RegisterFormData) => { + try { + await registerUser({ + phone: data.phone, + password: data.password, + fullName: data.fullName, + email: data.email || undefined, + }); + router.push('/'); + } catch { + // Error is handled by the store + } + }; + + return ( + + + Tạo tài khoản + Nhập thông tin để đăng ký tài khoản GoodGo + + +
+ {error && ( +
+ {error} + +
+ )} + +
+ + + {errors.fullName && ( +

{errors.fullName.message}

+ )} +
+ +
+ + + {errors.phone && ( +

{errors.phone.message}

+ )} +
+ +
+ + + {errors.email && ( +

{errors.email.message}

+ )} +
+ +
+
+ + +
+ + {errors.password && ( +

{errors.password.message}

+ )} +
+ +
+ + + {errors.confirmPassword && ( +

{errors.confirmPassword.message}

+ )} +
+ + +
+ +
+
+ +
+
+ Hoặc đăng ký với +
+
+ + +
+ +

+ Đã có tài khoản?{' '} + + Đăng nhập + +

+
+
+ ); +} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index b5c61c9..cc1a177 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -1,3 +1,34 @@ @tailwind base; @tailwind components; @tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --primary: 142.1 76.2% 36.3%; + --primary-foreground: 355.7 100% 97.3%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 142.1 76.2% 36.3%; + --radius: 0.5rem; + } + + * { + @apply border-border; + } + + body { + @apply bg-background text-foreground; + } +} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index b4afe86..855fae1 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,4 +1,5 @@ import type { Metadata } from 'next'; +import { AuthProvider } from '@/components/providers/auth-provider'; import './globals.css'; export const metadata: Metadata = { @@ -9,7 +10,9 @@ export const metadata: Metadata = { export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - {children} + + {children} + ); } diff --git a/apps/web/components/auth/oauth-buttons.tsx b/apps/web/components/auth/oauth-buttons.tsx new file mode 100644 index 0000000..4811c0c --- /dev/null +++ b/apps/web/components/auth/oauth-buttons.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { Button } from '@/components/ui/button'; + +const API_BASE_URL = process.env['NEXT_PUBLIC_API_URL'] || 'http://localhost:3001'; + +export function OAuthButtons() { + return ( +
+ + +
+ ); +} diff --git a/apps/web/components/providers/auth-provider.tsx b/apps/web/components/providers/auth-provider.tsx new file mode 100644 index 0000000..9328543 --- /dev/null +++ b/apps/web/components/providers/auth-provider.tsx @@ -0,0 +1,27 @@ +'use client'; + +import { useEffect } from 'react'; +import { useAuthStore } from '@/lib/auth-store'; + +function setAuthCookie(authenticated: boolean) { + if (authenticated) { + document.cookie = 'goodgo_authenticated=1; path=/; max-age=604800; SameSite=Lax'; + } else { + document.cookie = 'goodgo_authenticated=; path=/; max-age=0'; + } +} + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const initialize = useAuthStore((s) => s.initialize); + const tokens = useAuthStore((s) => s.tokens); + + useEffect(() => { + initialize(); + }, [initialize]); + + useEffect(() => { + setAuthCookie(!!tokens); + }, [tokens]); + + return <>{children}; +} diff --git a/apps/web/components/ui/button.tsx b/apps/web/components/ui/button.tsx new file mode 100644 index 0000000..e074a9d --- /dev/null +++ b/apps/web/components/ui/button.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { cn } from '@/lib/utils'; + +const buttonVariants = cva( + 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground hover:bg-primary/90', + destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90', + outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', + secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', + ghost: 'hover:bg-accent hover:text-accent-foreground', + link: 'text-primary underline-offset-4 hover:underline', + }, + size: { + default: 'h-10 px-4 py-2', + sm: 'h-9 rounded-md px-3', + lg: 'h-11 rounded-md px-8', + icon: 'h-10 w-10', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps {} + +const Button = React.forwardRef( + ({ className, variant, size, ...props }, ref) => { + return ( +