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:
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
16
apps/api/src/modules/notifications/domain/index.ts
Normal file
16
apps/api/src/modules/notifications/domain/index.ts
Normal 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';
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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[]>;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
];
|
||||||
1
apps/api/src/modules/notifications/index.ts
Normal file
1
apps/api/src/modules/notifications/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { NotificationsModule } from './notifications.module';
|
||||||
@@ -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';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
48
apps/api/src/modules/notifications/notifications.module.ts
Normal file
48
apps/api/src/modules/notifications/notifications.module.ts
Normal 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 {}
|
||||||
@@ -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() };
|
||||||
|
}
|
||||||
|
}
|
||||||
7
apps/web/app/(auth)/layout.tsx
Normal file
7
apps/web/app/(auth)/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
123
apps/web/app/(auth)/login/page.tsx
Normal file
123
apps/web/app/(auth)/login/page.tsx
Normal 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 và 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 có tài khoản?{' '}
|
||||||
|
<Link href="/register" className="font-medium text-primary hover:underline">
|
||||||
|
Đăng ký
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
173
apps/web/app/(auth)/register/page.tsx
Normal file
173
apps/web/app/(auth)/register/page.tsx
Normal 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 ký 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ọ và 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 ký
|
||||||
|
</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 ký với</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<OAuthButtons />
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="justify-center">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Đã có tài khoản?{' '}
|
||||||
|
<Link href="/login" className="font-medium text-primary hover:underline">
|
||||||
|
Đăng nhập
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
54
apps/web/components/auth/oauth-buttons.tsx
Normal file
54
apps/web/components/auth/oauth-buttons.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
apps/web/components/providers/auth-provider.tsx
Normal file
27
apps/web/components/providers/auth-provider.tsx
Normal 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}</>;
|
||||||
|
}
|
||||||
44
apps/web/components/ui/button.tsx
Normal file
44
apps/web/components/ui/button.tsx
Normal 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 };
|
||||||
51
apps/web/components/ui/card.tsx
Normal file
51
apps/web/components/ui/card.tsx
Normal 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 };
|
||||||
23
apps/web/components/ui/input.tsx
Normal file
23
apps/web/components/ui/input.tsx
Normal 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 };
|
||||||
20
apps/web/components/ui/label.tsx
Normal file
20
apps/web/components/ui/label.tsx
Normal 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 };
|
||||||
56
apps/web/lib/api-client.ts
Normal file
56
apps/web/lib/api-client.ts
Normal 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
42
apps/web/lib/auth-api.ts
Normal 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
123
apps/web/lib/auth-store.ts
Normal 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
6
apps/web/lib/utils.ts
Normal 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));
|
||||||
|
}
|
||||||
39
apps/web/lib/validations/auth.ts
Normal file
39
apps/web/lib/validations/auth.ts
Normal 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
30
apps/web/middleware.ts
Normal 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).*)'],
|
||||||
|
};
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user