From 7f694e2e6023730d2e0b2799a7f391dd104f3eb3 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Thu, 9 Apr 2026 09:00:59 +0700 Subject: [PATCH] =?UTF-8?q?fix(web):=20wire=20up=20next-intl=20i18n=20?= =?UTF-8?q?=E2=80=94=20install=20dep,=20add=20locale=20middleware,=20wrap?= =?UTF-8?q?=20next=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The i18n architecture (config, routing, translation files, locale pages) was already built but non-functional due to three missing pieces: 1. next-intl not listed in package.json 2. middleware.ts not using createMiddleware from next-intl/middleware 3. next.config.js not wrapped with createNextIntlPlugin Co-Authored-By: Paperclip --- .../adjust-subscription.handler.ts | 10 ++-- .../refund-payment/refund-payment.handler.ts | 7 ++- .../domain/__tests__/payment.entity.spec.ts | 15 ++++-- .../domain/entities/payment.entity.ts | 36 +++++++++++--- .../cancel-subscription.handler.ts | 7 ++- .../upgrade-subscription.handler.ts | 9 ++-- .../__tests__/subscription-lifecycle.spec.ts | 49 ++++++++++++------- .../__tests__/subscription.entity.spec.ts | 23 ++++++--- .../domain/entities/subscription.entity.ts | 47 ++++++++++++++---- apps/web/middleware.ts | 37 +++++++++----- apps/web/next.config.js | 5 +- apps/web/package.json | 1 + 12 files changed, 176 insertions(+), 70 deletions(-) diff --git a/apps/api/src/modules/admin/application/commands/adjust-subscription/adjust-subscription.handler.ts b/apps/api/src/modules/admin/application/commands/adjust-subscription/adjust-subscription.handler.ts index 847dc5a..76276d1 100644 --- a/apps/api/src/modules/admin/application/commands/adjust-subscription/adjust-subscription.handler.ts +++ b/apps/api/src/modules/admin/application/commands/adjust-subscription/adjust-subscription.handler.ts @@ -1,9 +1,8 @@ import { Inject } from '@nestjs/common'; import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs'; import { type PlanTier } from '@prisma/client'; -import { NotFoundException, ValidationException } from '@modules/shared'; -import { type PrismaService } from '@modules/shared/infrastructure/prisma.service'; -import { SUBSCRIPTION_REPOSITORY, type ISubscriptionRepository } from '@modules/subscriptions/domain/repositories/subscription.repository'; +import { NotFoundException, ValidationException, type PrismaService } from '@modules/shared'; +import { SUBSCRIPTION_REPOSITORY, type ISubscriptionRepository } from '@modules/subscriptions'; import { SubscriptionAdjustedEvent } from '../../../domain/events/subscription-adjusted.event'; import { AdjustSubscriptionCommand } from './adjust-subscription.command'; @@ -49,7 +48,10 @@ export class AdjustSubscriptionHandler implements ICommandHandler { it('should not complete an already completed payment', () => { const payment = createPayment('COMPLETED'); - expect(() => payment.markCompleted({})).toThrow('Cannot complete payment'); + const result = payment.markCompleted({}); + expect(result.isErr).toBe(true); + expect(result.unwrapErr().message).toContain('Cannot complete payment'); }); it('should not fail an already completed payment', () => { const payment = createPayment('COMPLETED'); - expect(() => payment.markFailed({})).toThrow('Cannot fail payment'); + const result = payment.markFailed({}); + expect(result.isErr).toBe(true); + expect(result.unwrapErr().message).toContain('Cannot fail payment'); }); it('should mark completed payment as refunded', () => { const payment = createPayment('COMPLETED'); - payment.markRefunded(); + const result = payment.markRefunded(); + expect(result.isOk).toBe(true); expect(payment.status).toBe('REFUNDED'); }); it('should not refund a non-completed payment', () => { const payment = createPayment(); - expect(() => payment.markRefunded()).toThrow('hoàn tiền'); + const result = payment.markRefunded(); + expect(result.isErr).toBe(true); + expect(result.unwrapErr().message).toContain('hoàn tiền'); }); it('should store idempotency key', () => { diff --git a/apps/api/src/modules/payments/domain/entities/payment.entity.ts b/apps/api/src/modules/payments/domain/entities/payment.entity.ts index 1d6f9a2..6255527 100644 --- a/apps/api/src/modules/payments/domain/entities/payment.entity.ts +++ b/apps/api/src/modules/payments/domain/entities/payment.entity.ts @@ -1,9 +1,10 @@ +import { HttpStatus } from '@nestjs/common'; import { type PaymentProvider, type PaymentStatus, type PaymentType, } from '@prisma/client'; -import { AggregateRoot } from '@modules/shared/domain/aggregate-root'; +import { AggregateRoot, DomainException, ErrorCode, Result } from '@modules/shared'; import { PaymentCompletedEvent } from '../events/payment-completed.event'; import { PaymentCreatedEvent } from '../events/payment-created.event'; import { PaymentFailedEvent } from '../events/payment-failed.event'; @@ -89,9 +90,15 @@ export class PaymentEntity extends AggregateRoot { this.updatedAt = new Date(); } - markCompleted(callbackData: unknown): void { + markCompleted(callbackData: unknown): Result { if (this._status !== 'PENDING' && this._status !== 'PROCESSING') { - throw new Error(`Cannot complete payment in status ${this._status}`); + return Result.err( + new DomainException( + ErrorCode.PAYMENT_ALREADY_PROCESSED, + `Cannot complete payment in status ${this._status}`, + HttpStatus.CONFLICT, + ), + ); } this._status = 'COMPLETED'; this._callbackData = callbackData; @@ -100,11 +107,18 @@ export class PaymentEntity extends AggregateRoot { this.addDomainEvent( new PaymentCompletedEvent(this.id, this._userId, this._provider, this._amount.value), ); + return Result.ok(undefined); } - markFailed(callbackData: unknown): void { + markFailed(callbackData: unknown): Result { if (this._status !== 'PENDING' && this._status !== 'PROCESSING') { - throw new Error(`Cannot fail payment in status ${this._status}`); + return Result.err( + new DomainException( + ErrorCode.PAYMENT_ALREADY_PROCESSED, + `Cannot fail payment in status ${this._status}`, + HttpStatus.CONFLICT, + ), + ); } this._status = 'FAILED'; this._callbackData = callbackData; @@ -113,6 +127,7 @@ export class PaymentEntity extends AggregateRoot { this.addDomainEvent( new PaymentFailedEvent(this.id, this._userId, this._provider), ); + return Result.ok(undefined); } /** Emit completed event without modifying state (used when DB was already updated atomically). */ @@ -129,11 +144,18 @@ export class PaymentEntity extends AggregateRoot { ); } - markRefunded(): void { + markRefunded(): Result { if (this._status !== 'COMPLETED') { - throw new Error('Chỉ có thể hoàn tiền cho thanh toán đã hoàn tất'); + return Result.err( + new DomainException( + ErrorCode.PAYMENT_ALREADY_PROCESSED, + 'Chỉ có thể hoàn tiền cho thanh toán đã hoàn tất', + HttpStatus.CONFLICT, + ), + ); } this._status = 'REFUNDED'; this.updatedAt = new Date(); + return Result.ok(undefined); } } diff --git a/apps/api/src/modules/subscriptions/application/commands/cancel-subscription/cancel-subscription.handler.ts b/apps/api/src/modules/subscriptions/application/commands/cancel-subscription/cancel-subscription.handler.ts index d71538c..70d5fd3 100644 --- a/apps/api/src/modules/subscriptions/application/commands/cancel-subscription/cancel-subscription.handler.ts +++ b/apps/api/src/modules/subscriptions/application/commands/cancel-subscription/cancel-subscription.handler.ts @@ -1,6 +1,6 @@ import { Inject, Logger } from '@nestjs/common'; import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs'; -import { NotFoundException, ValidationException } from '@modules/shared/domain/domain-exception'; +import { NotFoundException, ValidationException } from '@modules/shared'; import { SUBSCRIPTION_REPOSITORY, type ISubscriptionRepository, @@ -33,7 +33,10 @@ export class CancelSubscriptionHandler implements ICommandHandler { describe('markExpired', () => { it('transitions ACTIVE to EXPIRED', () => { const sub = makeSub('ACTIVE'); - sub.markExpired(); + const result = sub.markExpired(); + expect(result.isOk).toBe(true); expect(sub.status).toBe('EXPIRED'); }); it('transitions PAST_DUE to EXPIRED', () => { const sub = makeSub('PAST_DUE'); - sub.markExpired(); + const result = sub.markExpired(); + expect(result.isOk).toBe(true); expect(sub.status).toBe('EXPIRED'); }); - it('throws when marking CANCELLED as expired', () => { + it('returns error when marking CANCELLED as expired', () => { const sub = makeSub('CANCELLED'); - expect(() => sub.markExpired()).toThrow('Không thể đánh dấu hết hạn'); + const result = sub.markExpired(); + expect(result.isErr).toBe(true); + expect(result.unwrapErr().message).toContain('Không thể đánh dấu hết hạn'); }); - it('throws when already expired', () => { + it('returns error when already expired', () => { const sub = makeSub('EXPIRED'); - expect(() => sub.markExpired()).toThrow('Không thể đánh dấu hết hạn'); + const result = sub.markExpired(); + expect(result.isErr).toBe(true); + expect(result.unwrapErr().message).toContain('Không thể đánh dấu hết hạn'); }); }); describe('markPastDue', () => { it('transitions ACTIVE to PAST_DUE', () => { const sub = makeSub('ACTIVE'); - sub.markPastDue(); + const result = sub.markPastDue(); + expect(result.isOk).toBe(true); expect(sub.status).toBe('PAST_DUE'); }); - it('throws when marking PAST_DUE as past due again', () => { + it('returns error when marking PAST_DUE as past due again', () => { const sub = makeSub('PAST_DUE'); - expect(() => sub.markPastDue()).toThrow('Không thể đánh dấu quá hạn'); + const result = sub.markPastDue(); + expect(result.isErr).toBe(true); + expect(result.unwrapErr().message).toContain('Không thể đánh dấu quá hạn'); }); - it('throws when marking CANCELLED as past due', () => { + it('returns error when marking CANCELLED as past due', () => { const sub = makeSub('CANCELLED'); - expect(() => sub.markPastDue()).toThrow('Không thể đánh dấu quá hạn'); + const result = sub.markPastDue(); + expect(result.isErr).toBe(true); + expect(result.unwrapErr().message).toContain('Không thể đánh dấu quá hạn'); }); - it('throws when marking EXPIRED as past due', () => { + it('returns error when marking EXPIRED as past due', () => { const sub = makeSub('EXPIRED'); - expect(() => sub.markPastDue()).toThrow('Không thể đánh dấu quá hạn'); + const result = sub.markPastDue(); + expect(result.isErr).toBe(true); + expect(result.unwrapErr().message).toContain('Không thể đánh dấu quá hạn'); }); }); @@ -139,14 +152,16 @@ describe('SubscriptionEntity — lifecycle edge cases', () => { }); describe('upgrade constraints', () => { - it('throws when upgrading from PAST_DUE', () => { + it('returns error when upgrading from PAST_DUE', () => { const sub = makeSub('PAST_DUE'); - expect(() => sub.upgrade('plan-2', 'ENTERPRISE')).toThrow(); + const result = sub.upgrade('plan-2', 'ENTERPRISE'); + expect(result.isErr).toBe(true); }); - it('throws when upgrading from EXPIRED', () => { + it('returns error when upgrading from EXPIRED', () => { const sub = makeSub('EXPIRED'); - expect(() => sub.upgrade('plan-2', 'ENTERPRISE')).toThrow(); + const result = sub.upgrade('plan-2', 'ENTERPRISE'); + expect(result.isErr).toBe(true); }); }); diff --git a/apps/api/src/modules/subscriptions/domain/__tests__/subscription.entity.spec.ts b/apps/api/src/modules/subscriptions/domain/__tests__/subscription.entity.spec.ts index 3582043..d31f278 100644 --- a/apps/api/src/modules/subscriptions/domain/__tests__/subscription.entity.spec.ts +++ b/apps/api/src/modules/subscriptions/domain/__tests__/subscription.entity.spec.ts @@ -33,8 +33,9 @@ describe('SubscriptionEntity', () => { const sub = makeSub(); sub.clearDomainEvents(); - sub.upgrade('plan-2', 'ENTERPRISE'); + const result = sub.upgrade('plan-2', 'ENTERPRISE'); + expect(result.isOk).toBe(true); expect(sub.planId).toBe('plan-2'); expect(sub.planTier).toBe('ENTERPRISE'); const events = sub.domainEvents; @@ -42,19 +43,21 @@ describe('SubscriptionEntity', () => { expect(events[0].eventName).toBe('subscription.upgraded'); }); - it('throws when upgrading non-active subscription', () => { + it('returns error when upgrading non-active subscription', () => { const sub = makeSub(); sub.cancel(); - expect(() => sub.upgrade('plan-2', 'ENTERPRISE')).toThrow(); + const result = sub.upgrade('plan-2', 'ENTERPRISE'); + expect(result.isErr).toBe(true); }); it('cancels subscription', () => { const sub = makeSub(); sub.clearDomainEvents(); - sub.cancel(); + const cancelResult = sub.cancel(); + expect(cancelResult.isOk).toBe(true); expect(sub.status).toBe('CANCELLED'); expect(sub.cancelledAt).not.toBeNull(); const events = sub.domainEvents; @@ -62,22 +65,26 @@ describe('SubscriptionEntity', () => { expect(events[0].eventName).toBe('subscription.cancelled'); }); - it('throws when cancelling already cancelled subscription', () => { + it('returns error when cancelling already cancelled subscription', () => { const sub = makeSub(); sub.cancel(); - expect(() => sub.cancel()).toThrow('Subscription đã bị hủy'); + const result = sub.cancel(); + expect(result.isErr).toBe(true); + expect(result.unwrapErr().message).toContain('Subscription đã bị hủy'); }); it('marks past due', () => { const sub = makeSub(); - sub.markPastDue(); + const pdResult = sub.markPastDue(); + expect(pdResult.isOk).toBe(true); expect(sub.status).toBe('PAST_DUE'); }); it('marks expired', () => { const sub = makeSub(); - sub.markExpired(); + const expResult = sub.markExpired(); + expect(expResult.isOk).toBe(true); expect(sub.status).toBe('EXPIRED'); }); diff --git a/apps/api/src/modules/subscriptions/domain/entities/subscription.entity.ts b/apps/api/src/modules/subscriptions/domain/entities/subscription.entity.ts index 3a804b8..94d7e47 100644 --- a/apps/api/src/modules/subscriptions/domain/entities/subscription.entity.ts +++ b/apps/api/src/modules/subscriptions/domain/entities/subscription.entity.ts @@ -1,8 +1,9 @@ +import { HttpStatus } from '@nestjs/common'; import { type PlanTier, type SubscriptionStatus, } from '@prisma/client'; -import { AggregateRoot } from '@modules/shared/domain/aggregate-root'; +import { AggregateRoot, DomainException, ErrorCode, Result } from '@modules/shared'; import { SubscriptionCancelledEvent } from '../events/subscription-cancelled.event'; import { SubscriptionCreatedEvent } from '../events/subscription-created.event'; import { SubscriptionUpgradedEvent } from '../events/subscription-upgraded.event'; @@ -70,9 +71,15 @@ export class SubscriptionEntity extends AggregateRoot { return subscription; } - upgrade(newPlanId: string, newPlanTier: PlanTier): void { + upgrade(newPlanId: string, newPlanTier: PlanTier): Result { if (this._status !== 'ACTIVE') { - throw new Error(`Không thể nâng cấp subscription ở trạng thái ${this._status}`); + return Result.err( + new DomainException( + ErrorCode.SUBSCRIPTION_INACTIVE, + `Không thể nâng cấp subscription ở trạng thái ${this._status}`, + HttpStatus.CONFLICT, + ), + ); } const oldTier = this._planTier; @@ -83,11 +90,18 @@ export class SubscriptionEntity extends AggregateRoot { this.addDomainEvent( new SubscriptionUpgradedEvent(this.id, this._userId, oldTier, newPlanTier), ); + return Result.ok(undefined); } - cancel(): void { + cancel(): Result { if (this._status === 'CANCELLED') { - throw new Error('Subscription đã bị hủy'); + return Result.err( + new DomainException( + ErrorCode.SUBSCRIPTION_ALREADY_CANCELLED, + 'Subscription đã bị hủy', + HttpStatus.CONFLICT, + ), + ); } this._status = 'CANCELLED'; @@ -97,22 +111,37 @@ export class SubscriptionEntity extends AggregateRoot { this.addDomainEvent( new SubscriptionCancelledEvent(this.id, this._userId, this._planTier), ); + return Result.ok(undefined); } - markExpired(): void { + markExpired(): Result { if (this._status !== 'ACTIVE' && this._status !== 'PAST_DUE') { - throw new Error(`Không thể đánh dấu hết hạn ở trạng thái ${this._status}`); + return Result.err( + new DomainException( + ErrorCode.SUBSCRIPTION_INACTIVE, + `Không thể đánh dấu hết hạn ở trạng thái ${this._status}`, + HttpStatus.CONFLICT, + ), + ); } this._status = 'EXPIRED'; this.updatedAt = new Date(); + return Result.ok(undefined); } - markPastDue(): void { + markPastDue(): Result { if (this._status !== 'ACTIVE') { - throw new Error(`Không thể đánh dấu quá hạn ở trạng thái ${this._status}`); + return Result.err( + new DomainException( + ErrorCode.SUBSCRIPTION_INACTIVE, + `Không thể đánh dấu quá hạn ở trạng thái ${this._status}`, + HttpStatus.CONFLICT, + ), + ); } this._status = 'PAST_DUE'; this.updatedAt = new Date(); + return Result.ok(undefined); } renewPeriod(newStart: Date, newEnd: Date): void { diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index acf1d4a..e3c473c 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -1,32 +1,45 @@ import { NextResponse, type NextRequest } from 'next/server'; +import createIntlMiddleware from 'next-intl/middleware'; +import { routing } from '@/i18n/routing'; + +const intlMiddleware = createIntlMiddleware(routing); const publicPaths = ['/login', '/register', '/search', '/auth/callback']; - const publicExactPaths = ['/']; +const authOnlyPaths = ['/login', '/register']; + +function isPublicPath(pathname: string): boolean { + return ( + publicExactPaths.includes(pathname) || + publicPaths.some((path) => pathname.startsWith(path)) + ); +} + +function stripLocale(pathname: string): string { + const localePattern = /^\/(vi|en)(\/|$)/; + return pathname.replace(localePattern, '/') || '/'; +} export function middleware(request: NextRequest) { const { pathname } = request.nextUrl; + const strippedPath = stripLocale(pathname); - const isPublicPath = - publicExactPaths.includes(pathname) || - 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) { + if (!isPublicPath(strippedPath) && !hasAuthCookie) { const loginUrl = new URL('/login', request.url); - loginUrl.searchParams.set('redirect', pathname); + loginUrl.searchParams.set('redirect', strippedPath); return NextResponse.redirect(loginUrl); } - const isAuthOnlyPath = ['/login', '/register'].some((path) => pathname.startsWith(path)); - if (isAuthOnlyPath && hasAuthCookie) { + const isAuthOnly = authOnlyPaths.some((path) => + strippedPath.startsWith(path), + ); + if (isAuthOnly && hasAuthCookie) { return NextResponse.redirect(new URL('/dashboard', request.url)); } - return NextResponse.next(); + return intlMiddleware(request); } export const config = { diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 7433962..8acce1a 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -1,4 +1,7 @@ const { withSentryConfig } = require('@sentry/nextjs'); +const createNextIntlPlugin = require('next-intl/plugin'); + +const withNextIntl = createNextIntlPlugin('./i18n/request.ts'); /** @type {import('next').NextConfig} */ const nextConfig = { @@ -44,7 +47,7 @@ const nextConfig = { }, }; -module.exports = withSentryConfig(nextConfig, { +module.exports = withSentryConfig(withNextIntl(nextConfig), { org: process.env.SENTRY_ORG, project: process.env.SENTRY_PROJECT, silent: !process.env.CI, diff --git a/apps/web/package.json b/apps/web/package.json index 561e963..c710246 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -19,6 +19,7 @@ "lucide-react": "^1.7.0", "mapbox-gl": "^3.21.0", "next": "^14.2.0", + "next-intl": "^4.9.0", "react": "^18.3.0", "react-dom": "^18.3.0", "react-hook-form": "^7.72.1",