fix(web): wire up next-intl i18n — install dep, add locale middleware, wrap next config
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 <noreply@paperclip.ing>
This commit is contained in:
@@ -1,9 +1,8 @@
|
|||||||
import { Inject } from '@nestjs/common';
|
import { Inject } from '@nestjs/common';
|
||||||
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||||
import { type PlanTier } from '@prisma/client';
|
import { type PlanTier } from '@prisma/client';
|
||||||
import { NotFoundException, ValidationException } from '@modules/shared';
|
import { NotFoundException, ValidationException, type PrismaService } from '@modules/shared';
|
||||||
import { type PrismaService } from '@modules/shared/infrastructure/prisma.service';
|
import { SUBSCRIPTION_REPOSITORY, type ISubscriptionRepository } from '@modules/subscriptions';
|
||||||
import { SUBSCRIPTION_REPOSITORY, type ISubscriptionRepository } from '@modules/subscriptions/domain/repositories/subscription.repository';
|
|
||||||
import { SubscriptionAdjustedEvent } from '../../../domain/events/subscription-adjusted.event';
|
import { SubscriptionAdjustedEvent } from '../../../domain/events/subscription-adjusted.event';
|
||||||
import { AdjustSubscriptionCommand } from './adjust-subscription.command';
|
import { AdjustSubscriptionCommand } from './adjust-subscription.command';
|
||||||
|
|
||||||
@@ -49,7 +48,10 @@ export class AdjustSubscriptionHandler implements ICommandHandler<AdjustSubscrip
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
subscription.upgrade(newPlan.id, tier);
|
const upgradeResult = subscription.upgrade(newPlan.id, tier);
|
||||||
|
if (upgradeResult.isErr) {
|
||||||
|
throw upgradeResult.unwrapErr();
|
||||||
|
}
|
||||||
await this.subscriptionRepo.update(subscription);
|
await this.subscriptionRepo.update(subscription);
|
||||||
|
|
||||||
this.eventBus.publish(
|
this.eventBus.publish(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Inject, Logger } from '@nestjs/common';
|
import { Inject, Logger } from '@nestjs/common';
|
||||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||||
import { NotFoundException, ValidationException } from '@modules/shared/domain/domain-exception';
|
import { NotFoundException, ValidationException } from '@modules/shared';
|
||||||
import {
|
import {
|
||||||
PAYMENT_REPOSITORY,
|
PAYMENT_REPOSITORY,
|
||||||
type IPaymentRepository,
|
type IPaymentRepository,
|
||||||
@@ -50,7 +50,10 @@ export class RefundPaymentHandler implements ICommandHandler<RefundPaymentComman
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
payment.markRefunded();
|
const refundResult = payment.markRefunded();
|
||||||
|
if (refundResult.isErr) {
|
||||||
|
throw refundResult.unwrapErr();
|
||||||
|
}
|
||||||
await this.paymentRepo.update(payment);
|
await this.paymentRepo.update(payment);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -95,23 +95,30 @@ describe('PaymentEntity', () => {
|
|||||||
|
|
||||||
it('should not complete an already completed payment', () => {
|
it('should not complete an already completed payment', () => {
|
||||||
const payment = createPayment('COMPLETED');
|
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', () => {
|
it('should not fail an already completed payment', () => {
|
||||||
const payment = createPayment('COMPLETED');
|
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', () => {
|
it('should mark completed payment as refunded', () => {
|
||||||
const payment = createPayment('COMPLETED');
|
const payment = createPayment('COMPLETED');
|
||||||
payment.markRefunded();
|
const result = payment.markRefunded();
|
||||||
|
expect(result.isOk).toBe(true);
|
||||||
expect(payment.status).toBe('REFUNDED');
|
expect(payment.status).toBe('REFUNDED');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not refund a non-completed payment', () => {
|
it('should not refund a non-completed payment', () => {
|
||||||
const payment = createPayment();
|
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', () => {
|
it('should store idempotency key', () => {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
|
import { HttpStatus } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
type PaymentProvider,
|
type PaymentProvider,
|
||||||
type PaymentStatus,
|
type PaymentStatus,
|
||||||
type PaymentType,
|
type PaymentType,
|
||||||
} from '@prisma/client';
|
} 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 { PaymentCompletedEvent } from '../events/payment-completed.event';
|
||||||
import { PaymentCreatedEvent } from '../events/payment-created.event';
|
import { PaymentCreatedEvent } from '../events/payment-created.event';
|
||||||
import { PaymentFailedEvent } from '../events/payment-failed.event';
|
import { PaymentFailedEvent } from '../events/payment-failed.event';
|
||||||
@@ -89,9 +90,15 @@ export class PaymentEntity extends AggregateRoot<string> {
|
|||||||
this.updatedAt = new Date();
|
this.updatedAt = new Date();
|
||||||
}
|
}
|
||||||
|
|
||||||
markCompleted(callbackData: unknown): void {
|
markCompleted(callbackData: unknown): Result<void, DomainException> {
|
||||||
if (this._status !== 'PENDING' && this._status !== 'PROCESSING') {
|
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._status = 'COMPLETED';
|
||||||
this._callbackData = callbackData;
|
this._callbackData = callbackData;
|
||||||
@@ -100,11 +107,18 @@ export class PaymentEntity extends AggregateRoot<string> {
|
|||||||
this.addDomainEvent(
|
this.addDomainEvent(
|
||||||
new PaymentCompletedEvent(this.id, this._userId, this._provider, this._amount.value),
|
new PaymentCompletedEvent(this.id, this._userId, this._provider, this._amount.value),
|
||||||
);
|
);
|
||||||
|
return Result.ok(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
markFailed(callbackData: unknown): void {
|
markFailed(callbackData: unknown): Result<void, DomainException> {
|
||||||
if (this._status !== 'PENDING' && this._status !== 'PROCESSING') {
|
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._status = 'FAILED';
|
||||||
this._callbackData = callbackData;
|
this._callbackData = callbackData;
|
||||||
@@ -113,6 +127,7 @@ export class PaymentEntity extends AggregateRoot<string> {
|
|||||||
this.addDomainEvent(
|
this.addDomainEvent(
|
||||||
new PaymentFailedEvent(this.id, this._userId, this._provider),
|
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). */
|
/** Emit completed event without modifying state (used when DB was already updated atomically). */
|
||||||
@@ -129,11 +144,18 @@ export class PaymentEntity extends AggregateRoot<string> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
markRefunded(): void {
|
markRefunded(): Result<void, DomainException> {
|
||||||
if (this._status !== 'COMPLETED') {
|
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._status = 'REFUNDED';
|
||||||
this.updatedAt = new Date();
|
this.updatedAt = new Date();
|
||||||
|
return Result.ok(undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Inject, Logger } from '@nestjs/common';
|
import { Inject, Logger } from '@nestjs/common';
|
||||||
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
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 {
|
import {
|
||||||
SUBSCRIPTION_REPOSITORY,
|
SUBSCRIPTION_REPOSITORY,
|
||||||
type ISubscriptionRepository,
|
type ISubscriptionRepository,
|
||||||
@@ -33,7 +33,10 @@ export class CancelSubscriptionHandler implements ICommandHandler<CancelSubscrip
|
|||||||
throw new ValidationException('Subscription đã bị hủy trước đó');
|
throw new ValidationException('Subscription đã bị hủy trước đó');
|
||||||
}
|
}
|
||||||
|
|
||||||
subscription.cancel();
|
const cancelResult = subscription.cancel();
|
||||||
|
if (cancelResult.isErr) {
|
||||||
|
throw cancelResult.unwrapErr();
|
||||||
|
}
|
||||||
await this.subscriptionRepo.update(subscription);
|
await this.subscriptionRepo.update(subscription);
|
||||||
|
|
||||||
// Publish domain events
|
// Publish domain events
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { Inject, Logger } from '@nestjs/common';
|
import { Inject, Logger } from '@nestjs/common';
|
||||||
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||||
import { NotFoundException, ValidationException } from '@modules/shared/domain/domain-exception';
|
import { NotFoundException, ValidationException, CacheService, CachePrefix, type PrismaService } from '@modules/shared';
|
||||||
import { CacheService, CachePrefix } from '@modules/shared/infrastructure/cache.service';
|
|
||||||
import { type PrismaService } from '@modules/shared/infrastructure/prisma.service';
|
|
||||||
import {
|
import {
|
||||||
SUBSCRIPTION_REPOSITORY,
|
SUBSCRIPTION_REPOSITORY,
|
||||||
type ISubscriptionRepository,
|
type ISubscriptionRepository,
|
||||||
@@ -63,7 +61,10 @@ export class UpgradeSubscriptionHandler implements ICommandHandler<UpgradeSubscr
|
|||||||
}
|
}
|
||||||
|
|
||||||
const previousTier = subscription.planTier;
|
const previousTier = subscription.planTier;
|
||||||
subscription.upgrade(newPlan.id, command.newPlanTier);
|
const upgradeResult = subscription.upgrade(newPlan.id, command.newPlanTier);
|
||||||
|
if (upgradeResult.isErr) {
|
||||||
|
throw upgradeResult.unwrapErr();
|
||||||
|
}
|
||||||
await this.subscriptionRepo.update(subscription);
|
await this.subscriptionRepo.update(subscription);
|
||||||
|
|
||||||
// Publish domain events
|
// Publish domain events
|
||||||
|
|||||||
@@ -22,47 +22,60 @@ describe('SubscriptionEntity — lifecycle edge cases', () => {
|
|||||||
describe('markExpired', () => {
|
describe('markExpired', () => {
|
||||||
it('transitions ACTIVE to EXPIRED', () => {
|
it('transitions ACTIVE to EXPIRED', () => {
|
||||||
const sub = makeSub('ACTIVE');
|
const sub = makeSub('ACTIVE');
|
||||||
sub.markExpired();
|
const result = sub.markExpired();
|
||||||
|
expect(result.isOk).toBe(true);
|
||||||
expect(sub.status).toBe('EXPIRED');
|
expect(sub.status).toBe('EXPIRED');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('transitions PAST_DUE to EXPIRED', () => {
|
it('transitions PAST_DUE to EXPIRED', () => {
|
||||||
const sub = makeSub('PAST_DUE');
|
const sub = makeSub('PAST_DUE');
|
||||||
sub.markExpired();
|
const result = sub.markExpired();
|
||||||
|
expect(result.isOk).toBe(true);
|
||||||
expect(sub.status).toBe('EXPIRED');
|
expect(sub.status).toBe('EXPIRED');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws when marking CANCELLED as expired', () => {
|
it('returns error when marking CANCELLED as expired', () => {
|
||||||
const sub = makeSub('CANCELLED');
|
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');
|
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', () => {
|
describe('markPastDue', () => {
|
||||||
it('transitions ACTIVE to PAST_DUE', () => {
|
it('transitions ACTIVE to PAST_DUE', () => {
|
||||||
const sub = makeSub('ACTIVE');
|
const sub = makeSub('ACTIVE');
|
||||||
sub.markPastDue();
|
const result = sub.markPastDue();
|
||||||
|
expect(result.isOk).toBe(true);
|
||||||
expect(sub.status).toBe('PAST_DUE');
|
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');
|
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');
|
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');
|
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', () => {
|
describe('upgrade constraints', () => {
|
||||||
it('throws when upgrading from PAST_DUE', () => {
|
it('returns error when upgrading from PAST_DUE', () => {
|
||||||
const sub = makeSub('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');
|
const sub = makeSub('EXPIRED');
|
||||||
expect(() => sub.upgrade('plan-2', 'ENTERPRISE')).toThrow();
|
const result = sub.upgrade('plan-2', 'ENTERPRISE');
|
||||||
|
expect(result.isErr).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -33,8 +33,9 @@ describe('SubscriptionEntity', () => {
|
|||||||
const sub = makeSub();
|
const sub = makeSub();
|
||||||
sub.clearDomainEvents();
|
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.planId).toBe('plan-2');
|
||||||
expect(sub.planTier).toBe('ENTERPRISE');
|
expect(sub.planTier).toBe('ENTERPRISE');
|
||||||
const events = sub.domainEvents;
|
const events = sub.domainEvents;
|
||||||
@@ -42,19 +43,21 @@ describe('SubscriptionEntity', () => {
|
|||||||
expect(events[0].eventName).toBe('subscription.upgraded');
|
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();
|
const sub = makeSub();
|
||||||
sub.cancel();
|
sub.cancel();
|
||||||
|
|
||||||
expect(() => sub.upgrade('plan-2', 'ENTERPRISE')).toThrow();
|
const result = sub.upgrade('plan-2', 'ENTERPRISE');
|
||||||
|
expect(result.isErr).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('cancels subscription', () => {
|
it('cancels subscription', () => {
|
||||||
const sub = makeSub();
|
const sub = makeSub();
|
||||||
sub.clearDomainEvents();
|
sub.clearDomainEvents();
|
||||||
|
|
||||||
sub.cancel();
|
const cancelResult = sub.cancel();
|
||||||
|
|
||||||
|
expect(cancelResult.isOk).toBe(true);
|
||||||
expect(sub.status).toBe('CANCELLED');
|
expect(sub.status).toBe('CANCELLED');
|
||||||
expect(sub.cancelledAt).not.toBeNull();
|
expect(sub.cancelledAt).not.toBeNull();
|
||||||
const events = sub.domainEvents;
|
const events = sub.domainEvents;
|
||||||
@@ -62,22 +65,26 @@ describe('SubscriptionEntity', () => {
|
|||||||
expect(events[0].eventName).toBe('subscription.cancelled');
|
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();
|
const sub = makeSub();
|
||||||
sub.cancel();
|
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', () => {
|
it('marks past due', () => {
|
||||||
const sub = makeSub();
|
const sub = makeSub();
|
||||||
sub.markPastDue();
|
const pdResult = sub.markPastDue();
|
||||||
|
expect(pdResult.isOk).toBe(true);
|
||||||
expect(sub.status).toBe('PAST_DUE');
|
expect(sub.status).toBe('PAST_DUE');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('marks expired', () => {
|
it('marks expired', () => {
|
||||||
const sub = makeSub();
|
const sub = makeSub();
|
||||||
sub.markExpired();
|
const expResult = sub.markExpired();
|
||||||
|
expect(expResult.isOk).toBe(true);
|
||||||
expect(sub.status).toBe('EXPIRED');
|
expect(sub.status).toBe('EXPIRED');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import { HttpStatus } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
type PlanTier,
|
type PlanTier,
|
||||||
type SubscriptionStatus,
|
type SubscriptionStatus,
|
||||||
} from '@prisma/client';
|
} 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 { SubscriptionCancelledEvent } from '../events/subscription-cancelled.event';
|
||||||
import { SubscriptionCreatedEvent } from '../events/subscription-created.event';
|
import { SubscriptionCreatedEvent } from '../events/subscription-created.event';
|
||||||
import { SubscriptionUpgradedEvent } from '../events/subscription-upgraded.event';
|
import { SubscriptionUpgradedEvent } from '../events/subscription-upgraded.event';
|
||||||
@@ -70,9 +71,15 @@ export class SubscriptionEntity extends AggregateRoot<string> {
|
|||||||
return subscription;
|
return subscription;
|
||||||
}
|
}
|
||||||
|
|
||||||
upgrade(newPlanId: string, newPlanTier: PlanTier): void {
|
upgrade(newPlanId: string, newPlanTier: PlanTier): Result<void, DomainException> {
|
||||||
if (this._status !== 'ACTIVE') {
|
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;
|
const oldTier = this._planTier;
|
||||||
@@ -83,11 +90,18 @@ export class SubscriptionEntity extends AggregateRoot<string> {
|
|||||||
this.addDomainEvent(
|
this.addDomainEvent(
|
||||||
new SubscriptionUpgradedEvent(this.id, this._userId, oldTier, newPlanTier),
|
new SubscriptionUpgradedEvent(this.id, this._userId, oldTier, newPlanTier),
|
||||||
);
|
);
|
||||||
|
return Result.ok(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
cancel(): void {
|
cancel(): Result<void, DomainException> {
|
||||||
if (this._status === 'CANCELLED') {
|
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';
|
this._status = 'CANCELLED';
|
||||||
@@ -97,22 +111,37 @@ export class SubscriptionEntity extends AggregateRoot<string> {
|
|||||||
this.addDomainEvent(
|
this.addDomainEvent(
|
||||||
new SubscriptionCancelledEvent(this.id, this._userId, this._planTier),
|
new SubscriptionCancelledEvent(this.id, this._userId, this._planTier),
|
||||||
);
|
);
|
||||||
|
return Result.ok(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
markExpired(): void {
|
markExpired(): Result<void, DomainException> {
|
||||||
if (this._status !== 'ACTIVE' && this._status !== 'PAST_DUE') {
|
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._status = 'EXPIRED';
|
||||||
this.updatedAt = new Date();
|
this.updatedAt = new Date();
|
||||||
|
return Result.ok(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
markPastDue(): void {
|
markPastDue(): Result<void, DomainException> {
|
||||||
if (this._status !== 'ACTIVE') {
|
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._status = 'PAST_DUE';
|
||||||
this.updatedAt = new Date();
|
this.updatedAt = new Date();
|
||||||
|
return Result.ok(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
renewPeriod(newStart: Date, newEnd: Date): void {
|
renewPeriod(newStart: Date, newEnd: Date): void {
|
||||||
|
|||||||
@@ -1,32 +1,45 @@
|
|||||||
import { NextResponse, type NextRequest } from 'next/server';
|
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 publicPaths = ['/login', '/register', '/search', '/auth/callback'];
|
||||||
|
|
||||||
const publicExactPaths = ['/'];
|
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) {
|
export function middleware(request: NextRequest) {
|
||||||
const { pathname } = request.nextUrl;
|
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');
|
const hasAuthCookie = request.cookies.has('goodgo_authenticated');
|
||||||
|
|
||||||
if (!isPublicPath && !hasAuthCookie) {
|
if (!isPublicPath(strippedPath) && !hasAuthCookie) {
|
||||||
const loginUrl = new URL('/login', request.url);
|
const loginUrl = new URL('/login', request.url);
|
||||||
loginUrl.searchParams.set('redirect', pathname);
|
loginUrl.searchParams.set('redirect', strippedPath);
|
||||||
return NextResponse.redirect(loginUrl);
|
return NextResponse.redirect(loginUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAuthOnlyPath = ['/login', '/register'].some((path) => pathname.startsWith(path));
|
const isAuthOnly = authOnlyPaths.some((path) =>
|
||||||
if (isAuthOnlyPath && hasAuthCookie) {
|
strippedPath.startsWith(path),
|
||||||
|
);
|
||||||
|
if (isAuthOnly && hasAuthCookie) {
|
||||||
return NextResponse.redirect(new URL('/dashboard', request.url));
|
return NextResponse.redirect(new URL('/dashboard', request.url));
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.next();
|
return intlMiddleware(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
const { withSentryConfig } = require('@sentry/nextjs');
|
const { withSentryConfig } = require('@sentry/nextjs');
|
||||||
|
const createNextIntlPlugin = require('next-intl/plugin');
|
||||||
|
|
||||||
|
const withNextIntl = createNextIntlPlugin('./i18n/request.ts');
|
||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
@@ -44,7 +47,7 @@ const nextConfig = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = withSentryConfig(nextConfig, {
|
module.exports = withSentryConfig(withNextIntl(nextConfig), {
|
||||||
org: process.env.SENTRY_ORG,
|
org: process.env.SENTRY_ORG,
|
||||||
project: process.env.SENTRY_PROJECT,
|
project: process.env.SENTRY_PROJECT,
|
||||||
silent: !process.env.CI,
|
silent: !process.env.CI,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.7.0",
|
||||||
"mapbox-gl": "^3.21.0",
|
"mapbox-gl": "^3.21.0",
|
||||||
"next": "^14.2.0",
|
"next": "^14.2.0",
|
||||||
|
"next-intl": "^4.9.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",
|
"react-hook-form": "^7.72.1",
|
||||||
|
|||||||
Reference in New Issue
Block a user