chore: remediate CI blockers for production readiness

This commit is contained in:
Ho Ngoc Hai
2026-05-07 13:08:20 +07:00
parent f82806e06d
commit b35ec55126
32 changed files with 401 additions and 113 deletions

View File

@@ -91,6 +91,15 @@ JWT_EXPIRES_IN=15m
JWT_REFRESH_SECRET=<generate with: openssl rand -base64 48>
JWT_REFRESH_EXPIRES_IN=7d
# -----------------------------------------------------------------------------
# Seed / E2E Accounts
# -----------------------------------------------------------------------------
# Required when running `pnpm db:seed`. Use a local/test-only value.
# Do not reuse this password for any real production admin account.
SEED_DEFAULT_PASSWORD=
BCRYPT_ROUNDS=12
E2E_ADMIN_PHONE=0876677771
# -----------------------------------------------------------------------------
# OAuth Providers
# -----------------------------------------------------------------------------
@@ -110,11 +119,19 @@ FRONTEND_URL=http://localhost:3000
NEXT_PUBLIC_API_URL=http://localhost:3000
WEB_PORT=3001
# Demo accounts must stay disabled in production. To enable in a local demo,
# provide a JSON array of {phone,name,role,badgeClass} and a temporary password.
NEXT_PUBLIC_ENABLE_DEMO_ACCOUNTS=false
NEXT_PUBLIC_DEMO_PASSWORD=
NEXT_PUBLIC_DEMO_ACCOUNTS=
# -----------------------------------------------------------------------------
# AI Service (Python/FastAPI)
# -----------------------------------------------------------------------------
AI_SERVICE_PORT=8000
AI_SERVICE_URL=http://localhost:8000
AI_SERVICE_API_KEY=<optional-in-dev-required-in-prod>
AI_CORS_ORIGINS=http://localhost:3000,http://localhost:3001
CLAUDE_API_KEY=
# -----------------------------------------------------------------------------
@@ -221,7 +238,10 @@ SENTRY_PROJECT=
# Must be exactly 64 hex characters (32 bytes).
# openssl rand -hex 32
# -----------------------------------------------------------------------------
KYC_ENCRYPTION_KEY=<generate with: openssl rand -hex 32>
FIELD_ENCRYPTION_KEY=<generate with: openssl rand -hex 32>
FIELD_ENCRYPTION_KEY_VERSION=1
# Backward-compatible fallback accepted by the API; prefer FIELD_ENCRYPTION_KEY.
KYC_ENCRYPTION_KEY=
KYC_ENCRYPTION_KEY_VERSION=1
# -----------------------------------------------------------------------------

View File

@@ -51,6 +51,10 @@ CORS_ORIGINS=http://localhost:3010,http://localhost:3000
# Bcrypt (fast rounds for test — production uses 12+)
BCRYPT_ROUNDS=4
# Seeded admin used by E2E happy-path admin flows
SEED_DEFAULT_PASSWORD=Test@1234!
E2E_ADMIN_PHONE=0876677771
# OAuth (test stubs)
GOOGLE_CLIENT_ID=test-google-client-id
GOOGLE_CLIENT_SECRET=test-google-client-secret

View File

@@ -221,17 +221,17 @@ jobs:
[ "\$PREV_WEB" != "none" ] && docker tag "\$PREV_WEB" goodgo-web:rollback 2>/dev/null || true
[ "\$PREV_AI" != "none" ] && docker tag "\$PREV_AI" goodgo-ai-services:rollback 2>/dev/null || true
# Pull new images
docker compose -f docker-compose.prod.yml pull api web ai-services
# Apply migrations with the newly pulled API image before switching app containers.
docker compose -f docker-compose.prod.yml run --rm --no-deps api \
npx prisma migrate deploy --schema /app/prisma/schema.prisma
# Rolling update — zero downtime
docker compose -f docker-compose.prod.yml up -d --no-deps --wait api
docker compose -f docker-compose.prod.yml up -d --no-deps --wait web
docker compose -f docker-compose.prod.yml up -d --no-deps --wait ai-services
# Run database migrations
docker compose -f docker-compose.prod.yml exec -T api npx prisma migrate deploy
# NOTE: docker image prune is NOT run here — it runs after smoke tests pass
DEPLOY_SCRIPT
@@ -507,13 +507,15 @@ jobs:
docker compose -f docker-compose.prod.yml pull api web ai-services
# Apply migrations with the newly pulled API image before switching app containers.
docker compose -f docker-compose.prod.yml run --rm --no-deps api \
npx prisma migrate deploy --schema /app/prisma/schema.prisma
# Rolling update with health checks
docker compose -f docker-compose.prod.yml up -d --no-deps --wait api
docker compose -f docker-compose.prod.yml up -d --no-deps --wait web
docker compose -f docker-compose.prod.yml up -d --no-deps --wait ai-services
docker compose -f docker-compose.prod.yml exec -T api npx prisma migrate deploy
# NOTE: docker image prune is NOT run here — it runs after smoke tests pass
DEPLOY_SCRIPT
@@ -652,7 +654,7 @@ jobs:
rollback-production:
name: Rollback Production
needs: [smoke-test-production]
needs: [deploy-production, smoke-test-production]
if: failure()
runs-on: ubuntu-latest
environment: production

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 && (

View File

@@ -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: () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -71,7 +71,7 @@ export const subscriptionApi = {
}),
upgradeSubscription: (newPlanTier: string) =>
apiClient.post<{ message: string }>('/subscriptions/upgrade', {
apiClient.put<{ message: string }>('/subscriptions/upgrade', {
newPlanTier,
}),

View File

@@ -18,6 +18,7 @@ const publicPaths = [
'/du-an', // projects (real estate developments)
'/chuyen-nhuong', // property transfers
'/bang-gia', // pricing
'/pricing',
'/about',
'/contact',
'/privacy',

View File

@@ -16,17 +16,23 @@ services:
# Direct connection for migrations (bypasses PgBouncer — required for DDL)
DATABASE_URL_DIRECT: postgresql://${DB_USER}:${DB_PASSWORD}@postgres:5432/${DB_NAME}
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
REDIS_HOST: redis
REDIS_PORT: 6379
REDIS_PASSWORD: ${REDIS_PASSWORD}
CORS_ORIGINS: ${CORS_ORIGINS:?CORS_ORIGINS is required}
TYPESENSE_HOST: typesense
TYPESENSE_PORT: 8108
TYPESENSE_API_KEY: ${TYPESENSE_API_KEY}
JWT_SECRET: ${JWT_SECRET}
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET}
FIELD_ENCRYPTION_KEY: ${FIELD_ENCRYPTION_KEY:?FIELD_ENCRYPTION_KEY is required}
FIELD_ENCRYPTION_KEY_VERSION: ${FIELD_ENCRYPTION_KEY_VERSION:-1}
MINIO_ENDPOINT: minio
MINIO_PORT: 9000
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY}
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY}
AI_SERVICES_URL: http://ai-services:8000
AI_SERVICES_API_KEY: ${AI_API_KEY}
AI_SERVICE_URL: http://ai-services:8000
AI_SERVICE_API_KEY: ${AI_API_KEY}
RUN_MIGRATIONS: ${RUN_MIGRATIONS:-false}
depends_on:
pgbouncer:
@@ -107,6 +113,7 @@ services:
AI_DEBUG: 'false'
AI_LOG_LEVEL: info
AI_API_KEY: ${AI_API_KEY}
AI_CORS_ORIGINS: ${AI_CORS_ORIGINS:?AI_CORS_ORIGINS is required}
AI_RATE_LIMIT: ${AI_RATE_LIMIT:-60/minute}
healthcheck:
test: ['CMD', 'python', '-c', 'import httpx; httpx.get("http://localhost:8000/health").raise_for_status()']

View File

@@ -115,6 +115,8 @@ services:
environment:
AI_DEBUG: ${AI_DEBUG:-false}
AI_LOG_LEVEL: ${AI_LOG_LEVEL:-info}
AI_API_KEY: ${AI_API_KEY:-}
AI_CORS_ORIGINS: ${AI_CORS_ORIGINS:-http://localhost:3000,http://localhost:3001}
healthcheck:
test: ['CMD', 'python', '-c', 'import httpx; httpx.get("http://localhost:8000/health").raise_for_status()']
interval: 30s

View File

@@ -0,0 +1,69 @@
import { test, expect, registerUser, loginSeedAdmin } from '../fixtures';
import { createListing } from '../fixtures/listings.fixture';
test.describe('User-to-admin listing moderation flow', () => {
test('user creates listing, submits review, admin approves, and listing becomes active', async ({ request }) => {
const { accessToken: userToken } = await registerUser(request);
const title = `E2E User Admin Flow ${Date.now()}`;
const { listing } = await createListing(request, userToken, {
title,
address: `${Date.now()} Nguyễn Huệ`,
});
const listingId = listing.listingId as string;
expect(listingId).toBeTruthy();
expect(listing.status).toBe('DRAFT');
const submitRes = await request.patch(`listings/${listingId}/status`, {
data: { status: 'PENDING_REVIEW' },
headers: { Authorization: `Bearer ${userToken}` },
});
expect(submitRes.status()).toBe(200);
const submitBody = await submitRes.json();
expect(submitBody).toEqual(expect.objectContaining({ status: 'PENDING_REVIEW' }));
const { accessToken: adminToken } = await loginSeedAdmin(request);
const queueRes = await request.get('admin/moderation', {
params: { page: 1, limit: 100 },
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(queueRes.status()).toBe(200);
const queue = await queueRes.json();
expect(queue.data).toEqual(
expect.arrayContaining([
expect.objectContaining({
listingId,
propertyTitle: title,
}),
]),
);
const approveRes = await request.post('admin/moderation/approve', {
data: {
listingId,
moderationNotes: 'E2E admin approval',
},
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(approveRes.status()).toBe(201);
const approveBody = await approveRes.json();
expect(approveBody).toEqual(expect.objectContaining({ listingId, status: 'ACTIVE' }));
const detailRes = await request.get(`listings/${listingId}`);
expect(detailRes.status()).toBe(200);
const detail = await detailRes.json();
expect(detail.id).toBe(listingId);
expect(detail.status).toBe('ACTIVE');
const queueAfterApproveRes = await request.get('admin/moderation', {
params: { page: 1, limit: 100 },
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(queueAfterApproveRes.status()).toBe(200);
const queueAfterApprove = await queueAfterApproveRes.json();
expect(queueAfterApprove.data).not.toEqual(
expect.arrayContaining([expect.objectContaining({ listingId })]),
);
});
});

View File

@@ -50,6 +50,16 @@ export async function loginUser(
return res.json();
}
/** Logs in the seeded admin created by prisma/seed.ts for E2E admin happy paths. */
export async function loginSeedAdmin(request: APIRequestContext): Promise<TokenPair> {
const phone = process.env['E2E_ADMIN_PHONE'] ?? '0876677771';
const password = process.env['SEED_DEFAULT_PASSWORD'];
if (!password) {
throw new Error('SEED_DEFAULT_PASSWORD is required to log in the seeded admin user');
}
return loginUser(request, phone, password);
}
/**
* Extended test fixture that provides a pre-authenticated API context.
*

View File

@@ -1,5 +1,5 @@
export { test, expect } from './auth.fixture';
export { createTestUser, registerUser, loginUser } from './auth.fixture';
export { createTestUser, registerUser, loginUser, loginSeedAdmin } from './auth.fixture';
export type { TokenPair } from './auth.fixture';
export { createTestListing, createListing } from './listings.fixture';
export { buildVnpayCallbackData, buildMomoCallbackData } from './payments.fixture';

View File

@@ -43,26 +43,14 @@ export default async function globalSetup() {
env: { ...process.env, DATABASE_URL: databaseUrl },
};
// Apply schema to test database.
// Prisma 7 removed datasource.url from schema — the URL is in prisma.config.ts
// which picks it up from DATABASE_URL env var set above.
// For local dev, the test DB is typically set up manually or via pg_dump.
console.log('[E2E globalSetup] Verifying test database schema...');
try {
execSync('npx prisma db push --accept-data-loss --config prisma/prisma.config.ts', execOpts);
} catch (err) {
console.warn('[E2E globalSetup] prisma db push failed (may be expected in Prisma 7):', (err as Error).message);
console.log('[E2E globalSetup] Continuing — assuming test DB schema is already set up.');
}
// Apply committed migrations only. `db push --accept-data-loss` hides
// migration drift and can mutate the test schema outside review.
console.log('[E2E globalSetup] Applying test database migrations...');
execSync('npx prisma migrate deploy --config prisma/prisma.config.ts', execOpts);
// Seed database (upserts are idempotent)
console.log('[E2E globalSetup] Seeding test database...');
try {
execSync('npx prisma db seed --config prisma/prisma.config.ts', execOpts);
} catch (err) {
console.warn('[E2E globalSetup] Seed failed (may be expected if Prisma 7 config changed):', (err as Error).message);
console.log('[E2E globalSetup] Continuing — assuming test DB is already seeded.');
}
execSync('npx prisma db seed --config prisma/prisma.config.ts', execOpts);
console.log('[E2E globalSetup] Test database ready.\n');
}

View File

@@ -1,11 +1,13 @@
from fastapi import Depends, FastAPI
import hmac
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.util import get_remote_address
from app.config import settings
from app.middleware import verify_api_key
from app.routers import avm, avm_industrial, avm_v2, moderation, neighborhood, nlp
limiter = Limiter(key_func=get_remote_address, default_limits=[settings.rate_limit])
@@ -15,7 +17,6 @@ app = FastAPI(
version="0.1.0",
docs_url="/docs",
redoc_url="/redoc",
dependencies=[Depends(verify_api_key)],
)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
@@ -31,6 +32,24 @@ app.add_middleware(
allow_headers=["*"],
)
@app.middleware("http")
async def enforce_api_key(request: Request, call_next):
if request.url.path in {"/health", "/health/live"}:
return await call_next(request)
if not settings.api_key:
return await call_next(request)
api_key = request.headers.get("X-API-Key")
if not api_key or not hmac.compare_digest(api_key, settings.api_key):
return JSONResponse(
status_code=401,
content={"detail": "Invalid or missing API key"},
)
return await call_next(request)
app.include_router(avm.router)
app.include_router(avm_v2.router)
app.include_router(avm_industrial.router)
@@ -42,3 +61,8 @@ app.include_router(nlp.router)
@app.get("/health")
def health() -> dict:
return {"status": "ok", "service": settings.app_name}
@app.get("/health/live")
def live() -> dict:
return {"status": "ok", "service": settings.app_name}

View File

@@ -0,0 +1,7 @@
import os
os.environ.setdefault(
"AI_CORS_ORIGINS",
"http://localhost:3000,http://localhost:3001",
)

View File

@@ -115,7 +115,7 @@ export default defineConfig({
},
{
name: 'GoodGo Web',
command: `pnpm exec next dev --port ${WEB_PORT}`,
command: `rm -rf .next && pnpm exec next dev --port ${WEB_PORT}`,
cwd: './apps/web',
port: Number(WEB_PORT),
reuseExistingServer: !process.env.CI,

View File

@@ -13,11 +13,29 @@ const pool = new pg.Pool({ connectionString: process.env['DATABASE_URL'] });
const adapter = new PrismaPg(pool);
const prisma = new PrismaClient({ adapter });
const DEMO_PASSWORD = 'Velik@2026';
function getRequiredEnv(name: string): string {
const value = process.env[name]?.trim();
if (!value) {
throw new Error(`${name} must be set before running B2B seed`);
}
return value;
}
// Matches how RegisterUserHandler (HashedPassword.fromPlain) bcrypts, cost 12.
function getBcryptRounds(): number {
const raw = process.env['BCRYPT_ROUNDS'] ?? '12';
const rounds = Number.parseInt(raw, 10);
if (!Number.isInteger(rounds) || rounds < 4) {
throw new Error('BCRYPT_ROUNDS must be an integer >= 4');
}
return rounds;
}
const SEED_DEFAULT_PASSWORD = getRequiredEnv('SEED_DEFAULT_PASSWORD');
const BCRYPT_ROUNDS = getBcryptRounds();
// Matches RegisterUserHandler hashing while allowing faster rounds in tests.
async function hashPassword(raw: string): Promise<string> {
return bcrypt.hash(raw, 12);
return bcrypt.hash(raw, BCRYPT_ROUNDS);
}
function hash(value: string): string {
@@ -25,7 +43,7 @@ function hash(value: string): string {
}
async function main() {
const passwordHash = await hashPassword(DEMO_PASSWORD);
const passwordHash = await hashPassword(SEED_DEFAULT_PASSWORD);
// ── 1. DEVELOPER: CĐT Vingroup ──
const developerPhone = '+84912000001';
@@ -134,7 +152,7 @@ async function main() {
console.log(`DEVELOPER: ${developer.fullName} (${developerPhone}) — linked ${vingroupRes.count} projects`);
console.log(`DEVELOPER: ${devMaster.fullName} (${devMasterPhone}) — linked ${masterRes.count} projects`);
console.log(`PARK_OPERATOR: ${parkOp.fullName} (${parkPhone}) — linked ${parkLinked} KCN(s)`);
console.log('Password for all: ' + DEMO_PASSWORD);
console.log('Password for all: configured via SEED_DEFAULT_PASSWORD');
}
main()

View File

@@ -4,8 +4,10 @@
* Seeds ALL 27 models with realistic Vietnamese real estate data.
* Idempotent: safe to run multiple times (uses upsert + ON CONFLICT).
*
* Default admin account:
* Phone: 0876677771 | Email: hongochai10@icloud.com | Password: Velik@2026
* Seed admin account:
* Phone: 0876677771 | Email: hongochai10@icloud.com
*
* Set SEED_DEFAULT_PASSWORD before running this script.
*/
import * as crypto from 'node:crypto';
@@ -51,8 +53,25 @@ const prisma = new PrismaClient({ adapter });
// Constants
// =============================================================================
const DEFAULT_PASSWORD = 'Velik@2026';
const BCRYPT_ROUNDS = 12;
function getRequiredEnv(name: string): string {
const value = process.env[name]?.trim();
if (!value) {
throw new Error(`${name} must be set before running prisma seed`);
}
return value;
}
function getBcryptRounds(): number {
const raw = process.env['BCRYPT_ROUNDS'] ?? '12';
const rounds = Number.parseInt(raw, 10);
if (!Number.isInteger(rounds) || rounds < 4) {
throw new Error('BCRYPT_ROUNDS must be an integer >= 4');
}
return rounds;
}
const SEED_DEFAULT_PASSWORD = getRequiredEnv('SEED_DEFAULT_PASSWORD');
const BCRYPT_ROUNDS = getBcryptRounds();
const now = new Date();
const oneMonthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
@@ -133,7 +152,7 @@ async function seedUsers(passwordHash: string) {
});
}
console.log(`${users.length} users seeded (all with password: ${DEFAULT_PASSWORD})`);
console.log(`${users.length} users seeded (password configured via SEED_DEFAULT_PASSWORD)`);
}
// =============================================================================
@@ -429,7 +448,30 @@ async function seedProperties() {
${p.yearBuilt ?? null}, ${p.legalStatus ?? null}, ${p.amenities ?? null}::jsonb, ${null}::jsonb,
${null}, ${p.projectName ?? null}, NOW(), NOW()
)
ON CONFLICT ("id") DO NOTHING
ON CONFLICT ("id") DO UPDATE SET
"propertyType" = EXCLUDED."propertyType",
"title" = EXCLUDED."title",
"description" = EXCLUDED."description",
"address" = EXCLUDED."address",
"ward" = EXCLUDED."ward",
"district" = EXCLUDED."district",
"city" = EXCLUDED."city",
"location" = EXCLUDED."location",
"areaM2" = EXCLUDED."areaM2",
"usableAreaM2" = EXCLUDED."usableAreaM2",
"bedrooms" = EXCLUDED."bedrooms",
"bathrooms" = EXCLUDED."bathrooms",
"floors" = EXCLUDED."floors",
"floor" = EXCLUDED."floor",
"totalFloors" = EXCLUDED."totalFloors",
"direction" = EXCLUDED."direction",
"yearBuilt" = EXCLUDED."yearBuilt",
"legalStatus" = EXCLUDED."legalStatus",
"amenities" = EXCLUDED."amenities",
"nearbyPOIs" = EXCLUDED."nearbyPOIs",
"metroDistanceM" = EXCLUDED."metroDistanceM",
"projectName" = EXCLUDED."projectName",
"updatedAt" = NOW()
`;
}
@@ -742,8 +784,8 @@ async function main() {
console.log('━'.repeat(60));
// Pre-compute password hash
console.log('🔑 Hashing default password...');
const passwordHash = bcrypt.hashSync(DEFAULT_PASSWORD, BCRYPT_ROUNDS);
console.log('🔑 Hashing seed password...');
const passwordHash = bcrypt.hashSync(SEED_DEFAULT_PASSWORD, BCRYPT_ROUNDS);
console.log(` ✓ Password hash computed (bcrypt, ${BCRYPT_ROUNDS} rounds)\n`);
// Phase 1 — Plans
@@ -840,8 +882,8 @@ async function main() {
console.log('\n🔐 Admin Login:');
console.log(' Phone: 0876677771');
console.log(' Email: hongochai10@icloud.com');
console.log(' Password: Velik@2026');
console.log(' (All users share the same password)\n');
console.log(' Password: configured via SEED_DEFAULT_PASSWORD');
console.log(' (All seeded users share the configured seed password)\n');
}
main()