chore: remediate CI blockers for production readiness
This commit is contained in:
@@ -11,7 +11,7 @@ set -e
|
||||
|
||||
if [ "${RUN_MIGRATIONS}" = "true" ]; then
|
||||
echo "[entrypoint] Running Prisma migrations..."
|
||||
npx prisma migrate deploy --schema ./prisma/schema.prisma
|
||||
npx prisma migrate deploy --schema /app/prisma/schema.prisma
|
||||
echo "[entrypoint] Migrations complete."
|
||||
fi
|
||||
|
||||
|
||||
@@ -146,12 +146,14 @@ describe('UpdateListingStatusCommand', () => {
|
||||
'listing-1',
|
||||
'ACTIVE',
|
||||
'user-1',
|
||||
'ADMIN',
|
||||
'Đã xác minh thông tin',
|
||||
);
|
||||
|
||||
expect(command.listingId).toBe('listing-1');
|
||||
expect(command.newStatus).toBe('ACTIVE');
|
||||
expect(command.userId).toBe('user-1');
|
||||
expect(command.userRole).toBe('ADMIN');
|
||||
expect(command.moderationNotes).toBe('Đã xác minh thông tin');
|
||||
});
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ describe('UpdateListingStatusHandler', () => {
|
||||
const listing = createListing('listing-1', 'PENDING_REVIEW');
|
||||
mockListingRepo.findById.mockResolvedValue(listing);
|
||||
|
||||
const command = new UpdateListingStatusCommand('listing-1', 'ACTIVE', 'admin-1');
|
||||
const command = new UpdateListingStatusCommand('listing-1', 'ACTIVE', 'admin-1', 'ADMIN');
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.status).toBe('ACTIVE');
|
||||
@@ -64,7 +64,7 @@ describe('UpdateListingStatusHandler', () => {
|
||||
const listing = createListing('listing-1', 'PENDING_REVIEW');
|
||||
mockListingRepo.findById.mockResolvedValue(listing);
|
||||
|
||||
const command = new UpdateListingStatusCommand('listing-1', 'REJECTED', 'admin-1', 'Vi phạm chính sách');
|
||||
const command = new UpdateListingStatusCommand('listing-1', 'REJECTED', 'admin-1', 'ADMIN', 'Vi phạm chính sách');
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.status).toBe('REJECTED');
|
||||
@@ -74,7 +74,7 @@ describe('UpdateListingStatusHandler', () => {
|
||||
const listing = createListing('listing-1', 'ACTIVE');
|
||||
mockListingRepo.findById.mockResolvedValue(listing);
|
||||
|
||||
const command = new UpdateListingStatusCommand('listing-1', 'SOLD', 'seller-1');
|
||||
const command = new UpdateListingStatusCommand('listing-1', 'SOLD', 'seller-1', 'SELLER');
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.status).toBe('SOLD');
|
||||
@@ -83,7 +83,7 @@ describe('UpdateListingStatusHandler', () => {
|
||||
it('throws NotFoundException for non-existent listing', async () => {
|
||||
mockListingRepo.findById.mockResolvedValue(null);
|
||||
|
||||
const command = new UpdateListingStatusCommand('nonexistent', 'ACTIVE', 'admin-1');
|
||||
const command = new UpdateListingStatusCommand('nonexistent', 'ACTIVE', 'admin-1', 'ADMIN');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow('Listing');
|
||||
});
|
||||
@@ -92,8 +92,28 @@ describe('UpdateListingStatusHandler', () => {
|
||||
const listing = createListing('listing-1', 'DRAFT');
|
||||
mockListingRepo.findById.mockResolvedValue(listing);
|
||||
|
||||
const command = new UpdateListingStatusCommand('listing-1', 'SOLD', 'seller-1');
|
||||
const command = new UpdateListingStatusCommand('listing-1', 'SOLD', 'seller-1', 'SELLER');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(/trạng thái/);
|
||||
});
|
||||
|
||||
it('rejects moderation transitions from non-admin users', async () => {
|
||||
const listing = createListing('listing-1', 'PENDING_REVIEW');
|
||||
mockListingRepo.findById.mockResolvedValue(listing);
|
||||
|
||||
const command = new UpdateListingStatusCommand('listing-1', 'ACTIVE', 'seller-1', 'SELLER');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(/quản trị viên/);
|
||||
expect(mockListingRepo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects status updates from non-owner users', async () => {
|
||||
const listing = createListing('listing-1', 'ACTIVE');
|
||||
mockListingRepo.findById.mockResolvedValue(listing);
|
||||
|
||||
const command = new UpdateListingStatusCommand('listing-1', 'SOLD', 'other-user', 'SELLER');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow(/người bán/);
|
||||
expect(mockListingRepo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ export class UpdateListingStatusCommand {
|
||||
public readonly listingId: string,
|
||||
public readonly newStatus: ListingStatus,
|
||||
public readonly userId: string,
|
||||
public readonly userRole?: string,
|
||||
public readonly moderationNotes?: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { DomainException, NotFoundException, CacheService, CachePrefix, LoggerService } from '@modules/shared';
|
||||
import { DomainException, ForbiddenException, NotFoundException, CacheService, CachePrefix, LoggerService } from '@modules/shared';
|
||||
import { LISTING_REPOSITORY, type IListingRepository } from '../../../domain/repositories/listing.repository';
|
||||
import { ModerationService } from '../../../domain/services/moderation.service';
|
||||
import { UpdateListingStatusCommand } from './update-listing-status.command';
|
||||
@@ -22,6 +22,23 @@ export class UpdateListingStatusHandler implements ICommandHandler<UpdateListing
|
||||
throw new NotFoundException('Listing', command.listingId);
|
||||
}
|
||||
|
||||
const isAdmin = command.userRole === 'ADMIN';
|
||||
const isOwner = listing.sellerId === command.userId;
|
||||
const isAssignedAgent = listing.agentId !== null && listing.agentId === command.userId;
|
||||
const isModerationTransition =
|
||||
(listing.status === 'PENDING_REVIEW' && command.newStatus === 'ACTIVE') ||
|
||||
command.newStatus === 'REJECTED';
|
||||
|
||||
if (isModerationTransition && !isAdmin) {
|
||||
throw new ForbiddenException('Chỉ quản trị viên mới có thể duyệt hoặc từ chối tin đăng');
|
||||
}
|
||||
|
||||
if (!isAdmin && !isOwner && !isAssignedAgent) {
|
||||
throw new ForbiddenException(
|
||||
'Chỉ người bán, môi giới được giao hoặc quản trị viên mới có thể cập nhật trạng thái tin đăng',
|
||||
);
|
||||
}
|
||||
|
||||
this.moderationService.applyStatusTransition(
|
||||
listing,
|
||||
command.newStatus,
|
||||
|
||||
@@ -387,7 +387,7 @@ export class ListingsController {
|
||||
@CurrentUser() user: JwtPayload,
|
||||
): Promise<{ status: string }> {
|
||||
return this.commandBus.execute(
|
||||
new UpdateListingStatusCommand(id, dto.status, user.sub, dto.moderationNotes),
|
||||
new UpdateListingStatusCommand(id, dto.status, user.sub, user.role, dto.moderationNotes),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -90,6 +90,11 @@ describe('middleware – authentication guard', () => {
|
||||
expect(mockRedirectFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows unauthenticated user to reach /pricing', () => {
|
||||
middleware(makeRequest('/pricing', false));
|
||||
expect(mockRedirectFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows unauthenticated user to reach /login', () => {
|
||||
middleware(makeRequest('/login', false));
|
||||
expect(mockRedirectFn).not.toHaveBeenCalled();
|
||||
|
||||
@@ -16,21 +16,28 @@ import { Link } from '@/i18n/navigation';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { loginSchema, type LoginFormData } from '@/lib/validations/auth';
|
||||
|
||||
const DEMO_PASSWORD = 'Velik@2026';
|
||||
const ENABLE_DEMO_ACCOUNTS = process.env['NEXT_PUBLIC_ENABLE_DEMO_ACCOUNTS'] === 'true';
|
||||
const DEMO_PASSWORD = process.env['NEXT_PUBLIC_DEMO_PASSWORD'] ?? '';
|
||||
|
||||
const DEMO_ACCOUNTS: {
|
||||
type DemoAccount = {
|
||||
phone: string;
|
||||
name: string;
|
||||
role: 'ADMIN' | 'AGENT' | 'SELLER' | 'BUYER' | 'DEVELOPER' | 'PARK_OPERATOR';
|
||||
badgeClass: string;
|
||||
}[] = [
|
||||
{ phone: '+84876677771', name: 'Hồ Ngọc Hải', role: 'ADMIN', badgeClass: 'bg-red-500/10 text-red-600 border-red-500/20' },
|
||||
{ phone: '+84900000002', name: 'Nguyễn Văn An', role: 'AGENT', badgeClass: 'bg-blue-500/10 text-blue-600 border-blue-500/20' },
|
||||
{ phone: '+84900000005', name: 'Phạm Đức Dũng', role: 'SELLER', badgeClass: 'bg-amber-500/10 text-amber-600 border-amber-500/20' },
|
||||
{ phone: '+84900000004', name: 'Lê Minh Cường', role: 'BUYER', badgeClass: 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20' },
|
||||
{ phone: '+84912000001', name: 'CĐT Vingroup', role: 'DEVELOPER', badgeClass: 'bg-violet-500/10 text-violet-600 border-violet-500/20' },
|
||||
{ phone: '+84912000002', name: 'KCN VSIP', role: 'PARK_OPERATOR', badgeClass: 'bg-cyan-500/10 text-cyan-600 border-cyan-500/20' },
|
||||
];
|
||||
};
|
||||
|
||||
function parseDemoAccounts(): DemoAccount[] {
|
||||
const raw = process.env['NEXT_PUBLIC_DEMO_ACCOUNTS'];
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as DemoAccount[];
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const DEMO_ACCOUNTS = parseDemoAccounts();
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
@@ -39,6 +46,7 @@ export default function LoginPage() {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [demoOpen, setDemoOpen] = useState(true);
|
||||
const t = useTranslations('auth');
|
||||
const showDemoAccounts = ENABLE_DEMO_ACCOUNTS && DEMO_PASSWORD && DEMO_ACCOUNTS.length > 0;
|
||||
|
||||
const oauthError = searchParams.get('error');
|
||||
const oauthErrorMessage = oauthError
|
||||
@@ -76,48 +84,49 @@ export default function LoginPage() {
|
||||
<CardDescription>{t('loginDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Demo accounts panel — MVP only */}
|
||||
<div className="mb-4 rounded-lg border border-primary/20 bg-primary/5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDemoOpen(!demoOpen)}
|
||||
className="flex w-full items-center justify-between px-3 py-2 text-sm font-medium"
|
||||
aria-expanded={demoOpen}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-primary" aria-hidden="true" />
|
||||
{t('demoAccountsTitle')}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 text-muted-foreground transition-transform ${demoOpen ? 'rotate-180' : ''}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
{demoOpen && (
|
||||
<div className="space-y-2 border-t border-primary/20 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('demoAccountsHint')} <code className="rounded bg-muted px-1 py-0.5 font-mono text-xs">{DEMO_PASSWORD}</code>
|
||||
</p>
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{DEMO_ACCOUNTS.map((acc) => (
|
||||
<li key={acc.phone}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fillDemoAccount(acc.phone)}
|
||||
className="flex w-full items-center gap-2 rounded-md border bg-card px-2.5 py-1.5 text-left text-xs transition-colors hover:border-primary/40 hover:bg-primary/5"
|
||||
>
|
||||
<Badge variant="outline" className={`shrink-0 ${acc.badgeClass}`}>
|
||||
{acc.role}
|
||||
</Badge>
|
||||
<span className="flex-1 truncate font-medium">{acc.name}</span>
|
||||
<span className="font-mono text-muted-foreground">{acc.phone}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showDemoAccounts && (
|
||||
<div className="mb-4 rounded-lg border border-primary/20 bg-primary/5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDemoOpen(!demoOpen)}
|
||||
className="flex w-full items-center justify-between px-3 py-2 text-sm font-medium"
|
||||
aria-expanded={demoOpen}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-primary" aria-hidden="true" />
|
||||
{t('demoAccountsTitle')}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 text-muted-foreground transition-transform ${demoOpen ? 'rotate-180' : ''}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
{demoOpen && (
|
||||
<div className="space-y-2 border-t border-primary/20 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('demoAccountsHint')} <code className="rounded bg-muted px-1 py-0.5 font-mono text-xs">{DEMO_PASSWORD}</code>
|
||||
</p>
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{DEMO_ACCOUNTS.map((acc) => (
|
||||
<li key={acc.phone}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fillDemoAccount(acc.phone)}
|
||||
className="flex w-full items-center gap-2 rounded-md border bg-card px-2.5 py-1.5 text-left text-xs transition-colors hover:border-primary/40 hover:bg-primary/5"
|
||||
>
|
||||
<Badge variant="outline" className={`shrink-0 ${acc.badgeClass}`}>
|
||||
{acc.role}
|
||||
</Badge>
|
||||
<span className="flex-1 truncate font-medium">{acc.name}</span>
|
||||
<span className="font-mono text-muted-foreground">{acc.phone}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||
{oauthErrorMessage && (
|
||||
|
||||
@@ -15,6 +15,13 @@ vi.mock('next/link', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock locale-aware navigation links
|
||||
vi.mock('@/i18n/navigation', () => ({
|
||||
Link: ({ children, href }: { children: React.ReactNode; href: string }) => (
|
||||
<a href={href}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock next/dynamic to render children directly
|
||||
vi.mock('next/dynamic', () => ({
|
||||
default: () => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import Link from 'next/link';
|
||||
import * as React from 'react';
|
||||
import { AddToCompareButton } from '@/components/comparison/add-to-compare-button';
|
||||
import { AiAdviceCards } from '@/components/listings/ai-advice-cards';
|
||||
@@ -16,6 +15,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { AiEstimateButton } from '@/components/valuation/ai-estimate-button';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { analyticsApi, type NearbyPOI } from '@/lib/analytics-api';
|
||||
import { formatPrice, formatPricePerM2 } from '@/lib/currency';
|
||||
import { composeWhyThisLocation, derivePersonas } from '@/lib/listing-personas';
|
||||
|
||||
@@ -132,6 +132,32 @@ describe('CheckoutModal', () => {
|
||||
provider: 'VNPAY',
|
||||
type: 'SUBSCRIPTION',
|
||||
amountVND: 499000,
|
||||
returnUrl: 'http://localhost:3000/vi/payment/return',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the locale root payment return route from dashboard checkout', async () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
writable: true,
|
||||
value: {
|
||||
...window.location,
|
||||
href: 'http://localhost:3000/vi/dashboard/subscription',
|
||||
origin: 'http://localhost:3000',
|
||||
pathname: '/vi/dashboard/subscription',
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckoutModal open={true} onOpenChange={onOpenChange} plan={basePlan} billingCycle="monthly" />,
|
||||
);
|
||||
await userEvent.click(screen.getByRole('button', { name: /thanh toán/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreatePayment).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
returnUrl: 'http://localhost:3000/vi/payment/return',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -120,7 +120,9 @@ function CheckoutModalInner({
|
||||
}
|
||||
|
||||
// Step 2: Create payment and redirect to gateway
|
||||
const returnUrl = `${window.location.origin}${window.location.pathname.replace(/\/pricing$/, '')}/payment/return`;
|
||||
const localeMatch = window.location.pathname.match(/^\/(vi|en)(\/|$)/);
|
||||
const localePrefix = localeMatch?.[1] ? `/${localeMatch[1]}` : '';
|
||||
const returnUrl = `${window.location.origin}${localePrefix}/payment/return`;
|
||||
|
||||
const idempotencyKey = `sub-${plan.tier}-${billingCycle}-${Date.now()}`;
|
||||
|
||||
|
||||
@@ -176,7 +176,7 @@ export const adminApi = {
|
||||
apiClient.get<UserDetail>(`/admin/users/${userId}`),
|
||||
|
||||
updateUserStatus: (userId: string, isActive: boolean, reason?: string) =>
|
||||
apiClient.post<{ success: boolean }>('/admin/users/status', {
|
||||
apiClient.patch<{ success: boolean }>('/admin/users/status', {
|
||||
userId,
|
||||
isActive,
|
||||
reason,
|
||||
|
||||
@@ -121,6 +121,9 @@ export const apiClient = {
|
||||
patch: <T>(endpoint: string, body?: unknown, headers?: HeadersInit) =>
|
||||
request<T>(endpoint, { method: 'PATCH', body, headers }),
|
||||
|
||||
put: <T>(endpoint: string, body?: unknown, headers?: HeadersInit) =>
|
||||
request<T>(endpoint, { method: 'PUT', body, headers }),
|
||||
|
||||
delete: <T>(endpoint: string, headers?: HeadersInit) =>
|
||||
request<T>(endpoint, { method: 'DELETE', headers }),
|
||||
};
|
||||
|
||||
@@ -268,7 +268,7 @@ export const listingsApi = {
|
||||
},
|
||||
|
||||
updateStatus: (id: string, status: ListingStatus, moderationNotes?: string) =>
|
||||
apiClient.post<{ status: string }>(`/listings/${id}/status`, {
|
||||
apiClient.patch<{ status: string }>(`/listings/${id}/status`, {
|
||||
status,
|
||||
moderationNotes,
|
||||
}),
|
||||
|
||||
@@ -16,7 +16,9 @@ const API_BASE_URL = process.env['NEXT_PUBLIC_API_URL'] || 'http://localhost:300
|
||||
export async function fetchListingById(id: string): Promise<ListingDetail | null> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/listings/${id}`, {
|
||||
next: { revalidate: 300 }, // ISR: re-validate every 5 min
|
||||
// Listing detail includes mutable status, price, legal and moderation data.
|
||||
// Avoid serving stale details after admin/user actions.
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!res.ok) return null;
|
||||
|
||||
@@ -71,7 +71,7 @@ export const subscriptionApi = {
|
||||
}),
|
||||
|
||||
upgradeSubscription: (newPlanTier: string) =>
|
||||
apiClient.post<{ message: string }>('/subscriptions/upgrade', {
|
||||
apiClient.put<{ message: string }>('/subscriptions/upgrade', {
|
||||
newPlanTier,
|
||||
}),
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ const publicPaths = [
|
||||
'/du-an', // projects (real estate developments)
|
||||
'/chuyen-nhuong', // property transfers
|
||||
'/bang-gia', // pricing
|
||||
'/pricing',
|
||||
'/about',
|
||||
'/contact',
|
||||
'/privacy',
|
||||
|
||||
Reference in New Issue
Block a user