feat(notifications): add multi-channel notification module with Email, FCM, templates, and event listeners

- Domain: NotificationLog/NotificationPreference entities, repositories, channel value object
- Infrastructure: EmailService (nodemailer/SMTP), FcmService (firebase-admin), TemplateService (Handlebars)
- Application: SendNotification CQRS command, UserRegistered + AgentVerified event listeners
- Presentation: NotificationsController with history, preferences, and templates endpoints
- Prisma: NotificationLog and NotificationPreference models with proper indexes
- Templates: Vietnamese notification templates for user.registered, agent.verified, listing.approved, inquiry.received, password.reset

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-08 01:42:17 +07:00
parent 9301f44119
commit 0b29fac35e
42 changed files with 1720 additions and 6 deletions

View File

@@ -26,8 +26,11 @@
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.15.1", "class-validator": "^0.15.1",
"firebase-admin": "^13.7.0",
"handlebars": "^4.7.9",
"helmet": "^8.1.0", "helmet": "^8.1.0",
"ioredis": "^5.4.0", "ioredis": "^5.4.0",
"nodemailer": "^8.0.5",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"passport-local": "^1.0.0", "passport-local": "^1.0.0",
@@ -35,7 +38,8 @@
"pino-pretty": "^13.0.0", "pino-pretty": "^13.0.0",
"reflect-metadata": "^0.2.0", "reflect-metadata": "^0.2.0",
"rxjs": "^7.8.0", "rxjs": "^7.8.0",
"sanitize-html": "^2.17.2" "sanitize-html": "^2.17.2",
"typesense": "^3.0.5"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^11.0.0", "@nestjs/cli": "^11.0.0",
@@ -44,6 +48,7 @@
"@types/bcrypt": "^6.0.0", "@types/bcrypt": "^6.0.0",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/nodemailer": "^8.0.0",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.38", "@types/passport-local": "^1.0.38",
"@types/sanitize-html": "^2.16.1", "@types/sanitize-html": "^2.16.1",

View File

@@ -1,5 +1,6 @@
import { SharedModule } from '@modules/shared'; import { SharedModule } from '@modules/shared';
import { AuthModule } from '@modules/auth'; import { AuthModule } from '@modules/auth';
import { NotificationsModule } from '@modules/notifications';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core'; import { APP_GUARD } from '@nestjs/core';
import { CqrsModule } from '@nestjs/cqrs'; import { CqrsModule } from '@nestjs/cqrs';
@@ -12,6 +13,7 @@ import { AppController } from './app.controller';
CqrsModule.forRoot(), CqrsModule.forRoot(),
SharedModule, SharedModule,
AuthModule, AuthModule,
NotificationsModule,
// ── Rate Limiting ── // ── Rate Limiting ──
// Default: 60 requests per 60 seconds per IP // Default: 60 requests per 60 seconds per IP

View File

@@ -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<string, unknown>,
public readonly recipientAddress: string,
) {}
}

View File

@@ -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<SendNotificationCommand> {
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<void> {
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',
);
}
}
}

View File

@@ -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<void> {
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,
),
);
}
}

View File

@@ -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<void> {
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,
),
);
}
}
}

View File

@@ -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;
}

View File

@@ -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<string, unknown> | null;
status: NotificationStatus;
errorDetail: string | null;
sentAt: Date | null;
createdAt: Date;
}

View File

@@ -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,
) {}
}

View File

@@ -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';

View File

@@ -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<NotificationPreferenceEntity[]>;
isEnabled(userId: string, channel: NotificationChannel, eventType: string): Promise<boolean>;
upsert(
userId: string,
channel: NotificationChannel,
eventType: string,
enabled: boolean,
): Promise<NotificationPreferenceEntity>;
}

View File

@@ -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<string, unknown>;
}
export interface INotificationRepository {
create(dto: CreateNotificationDto): Promise<NotificationEntity>;
updateStatus(id: string, status: NotificationStatus, errorDetail?: string): Promise<void>;
findByUserId(userId: string, limit?: number): Promise<NotificationEntity[]>;
}

View File

@@ -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,
];

View File

@@ -0,0 +1 @@
export { NotificationsModule } from './notifications.module';

View File

@@ -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';

View File

@@ -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<NotificationPreferenceEntity[]> {
const records = await this.prisma.notificationPreference.findMany({
where: { userId },
});
return records as NotificationPreferenceEntity[];
}
async isEnabled(userId: string, channel: NotificationChannel, eventType: string): Promise<boolean> {
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<NotificationPreferenceEntity> {
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;
}
}

View File

@@ -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<NotificationEntity> {
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<void> {
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<NotificationEntity[]> {
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<string, unknown> | null,
status: record.status as NotificationStatus,
errorDetail: record.errorDetail,
sentAt: record.sentAt,
createdAt: record.createdAt,
};
}
}

View File

@@ -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<boolean> {
try {
await this.transporter.verify();
return true;
} catch {
return false;
}
}
}

View File

@@ -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<string, string>;
}
@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<string> {
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;
}
}
}

View File

@@ -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<string, TemplateDefinition> = {
'user.registered': {
subject: 'Chào mừng bạn đến với GoodGo!',
body: `<h1>Xin chào {{phone}}!</h1>
<p>Cảm ơn bạn đã đăng ký tài khoản GoodGo với vai trò <strong>{{role}}</strong>.</p>
<p>Bạn có thể bắt đầu khám phá bất động sản ngay bây giờ.</p>
<p>Trân trọng,<br/>Đội ngũ GoodGo</p>`,
},
'agent.verified': {
subject: 'Tài khoản Đại lý đã được xác minh',
body: `<h1>Chúc mừng!</h1>
<p>Tài khoản đại lý của bạn (ID: {{agentId}}) đã được xác minh thành công.</p>
<p>Bạn có thể bắt đầu đăng tin bất động sản ngay bây giờ.</p>
<p>Trân trọng,<br/>Đội ngũ GoodGo</p>`,
},
'listing.approved': {
subject: 'Tin đăng đã được duyệt',
body: `<h1>Tin đăng được phê duyệt!</h1>
<p>Tin đăng <strong>{{listingTitle}}</strong> của bạn đã được duyệt và hiển thị trên GoodGo.</p>
<p>Trân trọng,<br/>Đội ngũ GoodGo</p>`,
},
'inquiry.received': {
subject: 'Bạn có yêu cầu tư vấn mới',
body: `<h1>Yêu cầu tư vấn mới</h1>
<p>Bạn nhận được yêu cầu tư vấn từ <strong>{{senderName}}</strong> cho tin đăng <strong>{{listingTitle}}</strong>.</p>
<p>Nội dung: {{message}}</p>
<p>Trân trọng,<br/>Đội ngũ GoodGo</p>`,
},
'password.reset': {
subject: 'Đặt lại mật khẩu GoodGo',
body: `<h1>Đặt lại mật khẩu</h1>
<p>Bạn đã yêu cầu đặt lại mật khẩu. Sử dụng mã OTP sau: <strong>{{otp}}</strong></p>
<p>Mã có hiệu lực trong {{expiryMinutes}} phút.</p>
<p>Nếu bạn không yêu cầu, hãy bỏ qua email này.</p>
<p>Trân trọng,<br/>Đội ngũ GoodGo</p>`,
},
};
@Injectable()
export class TemplateService {
private compiledTemplates = new Map<string, { subject: Handlebars.TemplateDelegate; body: Handlebars.TemplateDelegate }>();
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<string, unknown>): 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());
}
}

View File

@@ -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 {}

View File

@@ -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() };
}
}

View File

@@ -0,0 +1,7 @@
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen items-center justify-center bg-muted/40 px-4 py-12">
<div className="w-full max-w-md">{children}</div>
</div>
);
}

View File

@@ -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<LoginFormData>({
resolver: zodResolver(loginSchema),
});
const onSubmit = async (data: LoginFormData) => {
try {
await login(data);
router.push('/');
} catch {
// Error is handled by the store
}
};
return (
<Card>
<CardHeader className="space-y-1 text-center">
<CardTitle className="text-2xl font-bold">Đăng nhập</CardTitle>
<CardDescription>Nhập số điện thoại mật khẩu đ đăng nhập</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive" role="alert">
{error}
<button type="button" onClick={clearError} className="ml-2 font-medium underline">
Bỏ qua
</button>
</div>
)}
<div className="space-y-2">
<Label htmlFor="phone">Số điện thoại</Label>
<Input
id="phone"
type="tel"
placeholder="0912345678"
autoComplete="tel"
{...register('phone')}
aria-invalid={!!errors.phone}
/>
{errors.phone && (
<p className="text-sm text-destructive" role="alert">{errors.phone.message}</p>
)}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">Mật khẩu</Label>
<button
type="button"
className="text-xs text-muted-foreground hover:text-primary"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? 'Ẩn' : 'Hiện'}
</button>
</div>
<Input
id="password"
type={showPassword ? 'text' : 'password'}
placeholder="Nhập mật khẩu"
autoComplete="current-password"
{...register('password')}
aria-invalid={!!errors.password}
/>
{errors.password && (
<p className="text-sm text-destructive" role="alert">{errors.password.message}</p>
)}
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Đăng nhập
</Button>
</form>
<div className="relative my-4">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-card px-2 text-muted-foreground">Hoặc đăng nhập với</span>
</div>
</div>
<OAuthButtons />
</CardContent>
<CardFooter className="justify-center">
<p className="text-sm text-muted-foreground">
Chưa tài khoản?{' '}
<Link href="/register" className="font-medium text-primary hover:underline">
Đăng
</Link>
</p>
</CardFooter>
</Card>
);
}

View File

@@ -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<RegisterFormData>({
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 (
<Card>
<CardHeader className="space-y-1 text-center">
<CardTitle className="text-2xl font-bold">Tạo tài khoản</CardTitle>
<CardDescription>Nhập thông tin đ đăng tài khoản GoodGo</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive" role="alert">
{error}
<button type="button" onClick={clearError} className="ml-2 font-medium underline">
Bỏ qua
</button>
</div>
)}
<div className="space-y-2">
<Label htmlFor="fullName">Họ tên</Label>
<Input
id="fullName"
type="text"
placeholder="Nguyễn Văn A"
autoComplete="name"
{...register('fullName')}
aria-invalid={!!errors.fullName}
/>
{errors.fullName && (
<p className="text-sm text-destructive" role="alert">{errors.fullName.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="phone">Số điện thoại</Label>
<Input
id="phone"
type="tel"
placeholder="0912345678"
autoComplete="tel"
{...register('phone')}
aria-invalid={!!errors.phone}
/>
{errors.phone && (
<p className="text-sm text-destructive" role="alert">{errors.phone.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="email">Email (tùy chọn)</Label>
<Input
id="email"
type="email"
placeholder="example@email.com"
autoComplete="email"
{...register('email')}
aria-invalid={!!errors.email}
/>
{errors.email && (
<p className="text-sm text-destructive" role="alert">{errors.email.message}</p>
)}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">Mật khẩu</Label>
<button
type="button"
className="text-xs text-muted-foreground hover:text-primary"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? 'Ẩn' : 'Hiện'}
</button>
</div>
<Input
id="password"
type={showPassword ? 'text' : 'password'}
placeholder="Ít nhất 8 ký tự"
autoComplete="new-password"
{...register('password')}
aria-invalid={!!errors.password}
/>
{errors.password && (
<p className="text-sm text-destructive" role="alert">{errors.password.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Xác nhận mật khẩu</Label>
<Input
id="confirmPassword"
type={showPassword ? 'text' : 'password'}
placeholder="Nhập lại mật khẩu"
autoComplete="new-password"
{...register('confirmPassword')}
aria-invalid={!!errors.confirmPassword}
/>
{errors.confirmPassword && (
<p className="text-sm text-destructive" role="alert">{errors.confirmPassword.message}</p>
)}
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Đăng
</Button>
</form>
<div className="relative my-4">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-card px-2 text-muted-foreground">Hoặc đăng với</span>
</div>
</div>
<OAuthButtons />
</CardContent>
<CardFooter className="justify-center">
<p className="text-sm text-muted-foreground">
Đã tài khoản?{' '}
<Link href="/login" className="font-medium text-primary hover:underline">
Đăng nhập
</Link>
</p>
</CardFooter>
</Card>
);
}

View File

@@ -1,3 +1,34 @@
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @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;
}
}

View File

@@ -1,4 +1,5 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { AuthProvider } from '@/components/providers/auth-provider';
import './globals.css'; import './globals.css';
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -9,7 +10,9 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="vi"> <html lang="vi">
<body>{children}</body> <body>
<AuthProvider>{children}</AuthProvider>
</body>
</html> </html>
); );
} }

View File

@@ -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 (
<div className="grid grid-cols-2 gap-3">
<Button
variant="outline"
type="button"
onClick={() => {
window.location.href = `${API_BASE_URL}/auth/google`;
}}
>
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24">
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
Google
</Button>
<Button
variant="outline"
type="button"
onClick={() => {
window.location.href = `${API_BASE_URL}/auth/zalo`;
}}
>
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24" fill="none">
<rect width="24" height="24" rx="4" fill="#0068FF" />
<text x="4" y="17" fontFamily="Arial" fontSize="12" fontWeight="bold" fill="white">
Z
</text>
</svg>
Zalo
</Button>
</div>
);
}

View File

@@ -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}</>;
}

View File

@@ -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<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => {
return (
<button className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
);
},
);
Button.displayName = 'Button';
export { Button, buttonVariants };

View File

@@ -0,0 +1,51 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)}
{...props}
/>
),
);
Card.displayName = 'Card';
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
),
);
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={cn('text-2xl font-semibold leading-none tracking-tight', className)} {...props} />
),
);
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
));
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
),
);
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
),
);
CardFooter.displayName = 'CardFooter';
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

View File

@@ -0,0 +1,23 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = 'Input';
export { Input };

View File

@@ -0,0 +1,20 @@
'use client';
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const labelVariants = cva(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
);
export interface LabelProps
extends React.LabelHTMLAttributes<HTMLLabelElement>,
VariantProps<typeof labelVariants> {}
const Label = React.forwardRef<HTMLLabelElement, LabelProps>(({ className, ...props }, ref) => {
return <label ref={ref} className={cn(labelVariants(), className)} {...props} />;
});
Label.displayName = 'Label';
export { Label };

View File

@@ -0,0 +1,56 @@
const API_BASE_URL = process.env['NEXT_PUBLIC_API_URL'] || 'http://localhost:3001';
export class ApiError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
this.name = 'ApiError';
}
}
type RequestOptions = Omit<RequestInit, 'body'> & {
body?: unknown;
};
async function request<T>(endpoint: string, options: RequestOptions = {}): Promise<T> {
const { body, headers, ...rest } = options;
const res = await fetch(`${API_BASE_URL}${endpoint}`, {
...rest,
headers: {
'Content-Type': 'application/json',
...headers,
},
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const error = await res.json().catch(() => ({ message: res.statusText }));
throw new ApiError(res.status, error.message || 'Request failed');
}
return res.json();
}
function authHeaders(token: string): HeadersInit {
return { Authorization: `Bearer ${token}` };
}
export const apiClient = {
get: <T>(endpoint: string, headers?: HeadersInit) =>
request<T>(endpoint, { method: 'GET', headers }),
post: <T>(endpoint: string, body?: unknown, headers?: HeadersInit) =>
request<T>(endpoint, { method: 'POST', body, headers }),
patch: <T>(endpoint: string, body?: unknown, headers?: HeadersInit) =>
request<T>(endpoint, { method: 'PATCH', body, headers }),
authGet: <T>(endpoint: string, token: string) =>
request<T>(endpoint, { method: 'GET', headers: authHeaders(token) }),
authPost: <T>(endpoint: string, token: string, body?: unknown) =>
request<T>(endpoint, { method: 'POST', body, headers: authHeaders(token) }),
};

42
apps/web/lib/auth-api.ts Normal file
View File

@@ -0,0 +1,42 @@
import { apiClient } from './api-client';
export interface TokenPair {
accessToken: string;
refreshToken: string;
expiresIn: number;
}
export interface UserProfile {
id: string;
email: string | null;
phone: string;
fullName: string;
avatarUrl: string | null;
role: string;
kycStatus: string;
isActive: boolean;
createdAt: string;
}
export interface RegisterPayload {
phone: string;
password: string;
fullName: string;
email?: string;
}
export interface LoginPayload {
phone: string;
password: string;
}
export const authApi = {
register: (data: RegisterPayload) => apiClient.post<TokenPair>('/auth/register', data),
login: (data: LoginPayload) => apiClient.post<TokenPair>('/auth/login', data),
refresh: (refreshToken: string) =>
apiClient.post<TokenPair>('/auth/refresh', { refreshToken }),
getProfile: (token: string) => apiClient.authGet<UserProfile>('/auth/profile', token),
};

123
apps/web/lib/auth-store.ts Normal file
View File

@@ -0,0 +1,123 @@
import { create } from 'zustand';
import { authApi, type TokenPair, type UserProfile, type LoginPayload, type RegisterPayload } from './auth-api';
import { ApiError } from './api-client';
const TOKEN_KEY = 'goodgo_tokens';
function persistTokens(tokens: TokenPair | null) {
if (typeof window === 'undefined') return;
if (tokens) {
localStorage.setItem(TOKEN_KEY, JSON.stringify(tokens));
} else {
localStorage.removeItem(TOKEN_KEY);
}
}
function loadTokens(): TokenPair | null {
if (typeof window === 'undefined') return null;
const raw = localStorage.getItem(TOKEN_KEY);
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
}
interface AuthState {
tokens: TokenPair | null;
user: UserProfile | null;
isLoading: boolean;
error: string | null;
login: (data: LoginPayload) => Promise<void>;
register: (data: RegisterPayload) => Promise<void>;
logout: () => void;
refreshToken: () => Promise<boolean>;
fetchProfile: () => Promise<void>;
initialize: () => Promise<void>;
clearError: () => void;
}
export const useAuthStore = create<AuthState>((set, get) => ({
tokens: null,
user: null,
isLoading: false,
error: null,
login: async (data) => {
set({ isLoading: true, error: null });
try {
const tokens = await authApi.login(data);
persistTokens(tokens);
set({ tokens, isLoading: false });
await get().fetchProfile();
} catch (e) {
const message = e instanceof ApiError ? e.message : 'Đăng nhập thất bại';
set({ isLoading: false, error: message });
throw e;
}
},
register: async (data) => {
set({ isLoading: true, error: null });
try {
const tokens = await authApi.register(data);
persistTokens(tokens);
set({ tokens, isLoading: false });
await get().fetchProfile();
} catch (e) {
const message = e instanceof ApiError ? e.message : 'Đăng ký thất bại';
set({ isLoading: false, error: message });
throw e;
}
},
logout: () => {
persistTokens(null);
set({ tokens: null, user: null, error: null });
},
refreshToken: async () => {
const { tokens } = get();
if (!tokens?.refreshToken) return false;
try {
const newTokens = await authApi.refresh(tokens.refreshToken);
persistTokens(newTokens);
set({ tokens: newTokens });
return true;
} catch {
get().logout();
return false;
}
},
fetchProfile: async () => {
const { tokens } = get();
if (!tokens?.accessToken) return;
try {
const user = await authApi.getProfile(tokens.accessToken);
set({ user });
} catch (e) {
if (e instanceof ApiError && e.status === 401) {
const refreshed = await get().refreshToken();
if (refreshed) {
const newTokens = get().tokens;
if (newTokens) {
const user = await authApi.getProfile(newTokens.accessToken);
set({ user });
}
}
}
}
},
initialize: async () => {
const tokens = loadTokens();
if (!tokens) return;
set({ tokens });
await get().fetchProfile();
},
clearError: () => set({ error: null }),
}));

6
apps/web/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -0,0 +1,39 @@
import { z } from 'zod';
const phoneRegex = /^(0|\+84)(3[2-9]|5[2689]|7[06-9]|8[1-9]|9[0-9])\d{7}$/;
export const loginSchema = z.object({
phone: z
.string()
.min(1, 'Vui lòng nhập số điện thoại')
.regex(phoneRegex, 'Số điện thoại không hợp lệ'),
password: z.string().min(1, 'Vui lòng nhập mật khẩu'),
});
export const registerSchema = z
.object({
fullName: z
.string()
.min(1, 'Vui lòng nhập họ tên')
.min(2, 'Họ tên phải có ít nhất 2 ký tự'),
phone: z
.string()
.min(1, 'Vui lòng nhập số điện thoại')
.regex(phoneRegex, 'Số điện thoại không hợp lệ'),
email: z
.string()
.email('Email không hợp lệ')
.optional()
.or(z.literal('')),
password: z
.string()
.min(8, 'Mật khẩu phải có ít nhất 8 ký tự'),
confirmPassword: z.string().min(1, 'Vui lòng xác nhận mật khẩu'),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Mật khẩu xác nhận không khớp',
path: ['confirmPassword'],
});
export type LoginFormData = z.infer<typeof loginSchema>;
export type RegisterFormData = z.infer<typeof registerSchema>;

30
apps/web/middleware.ts Normal file
View File

@@ -0,0 +1,30 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
const publicPaths = ['/login', '/register'];
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const isPublicPath = publicPaths.some((path) => pathname.startsWith(path));
// We check for the token cookie or rely on client-side auth store.
// For SSR-safe auth, check a lightweight cookie set by the client after login.
const hasAuthCookie = request.cookies.has('goodgo_authenticated');
if (!isPublicPath && !hasAuthCookie) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('redirect', pathname);
return NextResponse.redirect(loginUrl);
}
if (isPublicPath && hasAuthCookie) {
return NextResponse.redirect(new URL('/', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico|public).*)'],
};

View File

@@ -10,9 +10,17 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.2.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.7.0",
"next": "^14.2.0", "next": "^14.2.0",
"react": "^18.3.0", "react": "^18.3.0",
"react-dom": "^18.3.0" "react-dom": "^18.3.0",
"react-hook-form": "^7.72.1",
"tailwind-merge": "^3.5.0",
"zod": "^4.3.6",
"zustand": "^5.0.12"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
@@ -21,6 +29,7 @@
"autoprefixer": "^10.4.0", "autoprefixer": "^10.4.0",
"postcss": "^8.4.0", "postcss": "^8.4.0",
"tailwindcss": "^3.4.0", "tailwindcss": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.7.0" "typescript": "^5.7.0"
} }
} }

View File

@@ -1,11 +1,50 @@
import type { Config } from 'tailwindcss'; import type { Config } from 'tailwindcss';
import tailwindcssAnimate from 'tailwindcss-animate';
const config: Config = { const config: Config = {
content: ['./app/**/*.{ts,tsx}', './components/**/*.{ts,tsx}'], darkMode: ['class'],
content: ['./app/**/*.{ts,tsx}', './components/**/*.{ts,tsx}', './lib/**/*.{ts,tsx}'],
theme: { theme: {
extend: {}, extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
},
}, },
plugins: [], plugins: [tailwindcssAnimate],
}; };
export default config; export default config;

View File

@@ -476,6 +476,56 @@ model MarketIndex {
@@index([city, period]) @@index([city, period])
} }
// =============================================================================
// NOTIFICATIONS
// =============================================================================
enum NotificationChannel {
EMAIL
SMS
PUSH
ZALO_OA
}
enum NotificationStatus {
PENDING
SENT
FAILED
DELIVERED
}
model NotificationLog {
id String @id @default(cuid())
userId String
channel NotificationChannel
templateKey String
subject String?
body String @db.Text
metadata Json?
status NotificationStatus @default(PENDING)
errorDetail String?
sentAt DateTime?
createdAt DateTime @default(now())
@@index([userId])
@@index([channel, status])
@@index([templateKey])
@@index([createdAt])
}
model NotificationPreference {
id String @id @default(cuid())
userId String
channel NotificationChannel
eventType String
enabled Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId, channel, eventType])
@@index([userId])
}
// ============================================================================= // =============================================================================
// REVIEWS // REVIEWS
// ============================================================================= // =============================================================================