diff --git a/.env.example b/.env.example index d9d1312..2f1934e 100644 --- a/.env.example +++ b/.env.example @@ -91,6 +91,15 @@ JWT_EXPIRES_IN=15m JWT_REFRESH_SECRET= 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= +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= +FIELD_ENCRYPTION_KEY= +FIELD_ENCRYPTION_KEY_VERSION=1 +# Backward-compatible fallback accepted by the API; prefer FIELD_ENCRYPTION_KEY. +KYC_ENCRYPTION_KEY= KYC_ENCRYPTION_KEY_VERSION=1 # ----------------------------------------------------------------------------- diff --git a/.env.test b/.env.test index 2c1ec80..fbad11a 100644 --- a/.env.test +++ b/.env.test @@ -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 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ad37461..6a7ba05 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -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 diff --git a/apps/api/docker-entrypoint.sh b/apps/api/docker-entrypoint.sh index 6a19eca..6ecb62c 100755 --- a/apps/api/docker-entrypoint.sh +++ b/apps/api/docker-entrypoint.sh @@ -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 diff --git a/apps/api/src/modules/listings/application/__tests__/commands.spec.ts b/apps/api/src/modules/listings/application/__tests__/commands.spec.ts index da4aa2f..94e67c3 100644 --- a/apps/api/src/modules/listings/application/__tests__/commands.spec.ts +++ b/apps/api/src/modules/listings/application/__tests__/commands.spec.ts @@ -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'); }); diff --git a/apps/api/src/modules/listings/application/__tests__/update-listing-status.handler.spec.ts b/apps/api/src/modules/listings/application/__tests__/update-listing-status.handler.spec.ts index dcca32e..6ae930e 100644 --- a/apps/api/src/modules/listings/application/__tests__/update-listing-status.handler.spec.ts +++ b/apps/api/src/modules/listings/application/__tests__/update-listing-status.handler.spec.ts @@ -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(); + }); }); diff --git a/apps/api/src/modules/listings/application/commands/update-listing-status/update-listing-status.command.ts b/apps/api/src/modules/listings/application/commands/update-listing-status/update-listing-status.command.ts index afd124f..dbbfc7f 100644 --- a/apps/api/src/modules/listings/application/commands/update-listing-status/update-listing-status.command.ts +++ b/apps/api/src/modules/listings/application/commands/update-listing-status/update-listing-status.command.ts @@ -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, ) {} } diff --git a/apps/api/src/modules/listings/application/commands/update-listing-status/update-listing-status.handler.ts b/apps/api/src/modules/listings/application/commands/update-listing-status/update-listing-status.handler.ts index 1378909..0df4a91 100644 --- a/apps/api/src/modules/listings/application/commands/update-listing-status/update-listing-status.handler.ts +++ b/apps/api/src/modules/listings/application/commands/update-listing-status/update-listing-status.handler.ts @@ -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 { return this.commandBus.execute( - new UpdateListingStatusCommand(id, dto.status, user.sub, dto.moderationNotes), + new UpdateListingStatusCommand(id, dto.status, user.sub, user.role, dto.moderationNotes), ); } diff --git a/apps/web/__tests__/middleware.spec.ts b/apps/web/__tests__/middleware.spec.ts index 544c88b..ccf3a06 100644 --- a/apps/web/__tests__/middleware.spec.ts +++ b/apps/web/__tests__/middleware.spec.ts @@ -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(); diff --git a/apps/web/app/[locale]/(auth)/login/page.tsx b/apps/web/app/[locale]/(auth)/login/page.tsx index 6ba3e82..9b15ec9 100644 --- a/apps/web/app/[locale]/(auth)/login/page.tsx +++ b/apps/web/app/[locale]/(auth)/login/page.tsx @@ -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() { {t('loginDescription')} - {/* Demo accounts panel — MVP only */} -
- - {demoOpen && ( -
-

- {t('demoAccountsHint')} {DEMO_PASSWORD} -

-
    - {DEMO_ACCOUNTS.map((acc) => ( -
  • - -
  • - ))} -
-
- )} -
+ {showDemoAccounts && ( +
+ + {demoOpen && ( +
+

+ {t('demoAccountsHint')} {DEMO_PASSWORD} +

+
    + {DEMO_ACCOUNTS.map((acc) => ( +
  • + +
  • + ))} +
+
+ )} +
+ )}
{oauthErrorMessage && ( diff --git a/apps/web/components/listings/__tests__/listing-detail-client.spec.tsx b/apps/web/components/listings/__tests__/listing-detail-client.spec.tsx index 76d3f63..3edee70 100644 --- a/apps/web/components/listings/__tests__/listing-detail-client.spec.tsx +++ b/apps/web/components/listings/__tests__/listing-detail-client.spec.tsx @@ -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 }) => ( + {children} + ), +})); + // Mock next/dynamic to render children directly vi.mock('next/dynamic', () => ({ default: () => { diff --git a/apps/web/components/listings/listing-detail-client.tsx b/apps/web/components/listings/listing-detail-client.tsx index 6f72b67..0b7daef 100644 --- a/apps/web/components/listings/listing-detail-client.tsx +++ b/apps/web/components/listings/listing-detail-client.tsx @@ -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'; diff --git a/apps/web/components/subscription/__tests__/checkout-modal.spec.tsx b/apps/web/components/subscription/__tests__/checkout-modal.spec.tsx index e529b42..ab6dc86 100644 --- a/apps/web/components/subscription/__tests__/checkout-modal.spec.tsx +++ b/apps/web/components/subscription/__tests__/checkout-modal.spec.tsx @@ -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( + , + ); + 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', }), ); }); diff --git a/apps/web/components/subscription/checkout-modal.tsx b/apps/web/components/subscription/checkout-modal.tsx index 548a9c1..9e0fe74 100644 --- a/apps/web/components/subscription/checkout-modal.tsx +++ b/apps/web/components/subscription/checkout-modal.tsx @@ -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()}`; diff --git a/apps/web/lib/admin-api.ts b/apps/web/lib/admin-api.ts index 348490e..da3cb65 100644 --- a/apps/web/lib/admin-api.ts +++ b/apps/web/lib/admin-api.ts @@ -176,7 +176,7 @@ export const adminApi = { apiClient.get(`/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, diff --git a/apps/web/lib/api-client.ts b/apps/web/lib/api-client.ts index f148980..078117c 100644 --- a/apps/web/lib/api-client.ts +++ b/apps/web/lib/api-client.ts @@ -121,6 +121,9 @@ export const apiClient = { patch: (endpoint: string, body?: unknown, headers?: HeadersInit) => request(endpoint, { method: 'PATCH', body, headers }), + put: (endpoint: string, body?: unknown, headers?: HeadersInit) => + request(endpoint, { method: 'PUT', body, headers }), + delete: (endpoint: string, headers?: HeadersInit) => request(endpoint, { method: 'DELETE', headers }), }; diff --git a/apps/web/lib/listings-api.ts b/apps/web/lib/listings-api.ts index e6fef2c..475cd16 100644 --- a/apps/web/lib/listings-api.ts +++ b/apps/web/lib/listings-api.ts @@ -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, }), diff --git a/apps/web/lib/listings-server.ts b/apps/web/lib/listings-server.ts index eeb0ba8..839d056 100644 --- a/apps/web/lib/listings-server.ts +++ b/apps/web/lib/listings-server.ts @@ -16,7 +16,9 @@ const API_BASE_URL = process.env['NEXT_PUBLIC_API_URL'] || 'http://localhost:300 export async function fetchListingById(id: string): Promise { 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; diff --git a/apps/web/lib/subscription-api.ts b/apps/web/lib/subscription-api.ts index eee3a88..0ace6b7 100644 --- a/apps/web/lib/subscription-api.ts +++ b/apps/web/lib/subscription-api.ts @@ -71,7 +71,7 @@ export const subscriptionApi = { }), upgradeSubscription: (newPlanTier: string) => - apiClient.post<{ message: string }>('/subscriptions/upgrade', { + apiClient.put<{ message: string }>('/subscriptions/upgrade', { newPlanTier, }), diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index bcce95b..9d8be98 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -18,6 +18,7 @@ const publicPaths = [ '/du-an', // projects (real estate developments) '/chuyen-nhuong', // property transfers '/bang-gia', // pricing + '/pricing', '/about', '/contact', '/privacy', diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index f0b44c4..e967dff 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -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()'] diff --git a/docker-compose.yml b/docker-compose.yml index 16433eb..5f5652d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/e2e/api/user-admin-listing-flow.spec.ts b/e2e/api/user-admin-listing-flow.spec.ts new file mode 100644 index 0000000..8d75842 --- /dev/null +++ b/e2e/api/user-admin-listing-flow.spec.ts @@ -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 })]), + ); + }); +}); diff --git a/e2e/fixtures/auth.fixture.ts b/e2e/fixtures/auth.fixture.ts index 8af25a1..5b29eb4 100644 --- a/e2e/fixtures/auth.fixture.ts +++ b/e2e/fixtures/auth.fixture.ts @@ -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 { + 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. * diff --git a/e2e/fixtures/index.ts b/e2e/fixtures/index.ts index 55c8396..033f6fe 100644 --- a/e2e/fixtures/index.ts +++ b/e2e/fixtures/index.ts @@ -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'; diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts index 87433db..76451db 100644 --- a/e2e/global-setup.ts +++ b/e2e/global-setup.ts @@ -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'); } diff --git a/libs/ai-services/app/main.py b/libs/ai-services/app/main.py index 324a7ac..8c63e7d 100644 --- a/libs/ai-services/app/main.py +++ b/libs/ai-services/app/main.py @@ -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} diff --git a/libs/ai-services/tests/conftest.py b/libs/ai-services/tests/conftest.py new file mode 100644 index 0000000..f25ef12 --- /dev/null +++ b/libs/ai-services/tests/conftest.py @@ -0,0 +1,7 @@ +import os + + +os.environ.setdefault( + "AI_CORS_ORIGINS", + "http://localhost:3000,http://localhost:3001", +) diff --git a/playwright.config.ts b/playwright.config.ts index c2b990b..b7a512a 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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, diff --git a/prisma/seed-b2b-accounts.ts b/prisma/seed-b2b-accounts.ts index 1e3ccab..109e88e 100644 --- a/prisma/seed-b2b-accounts.ts +++ b/prisma/seed-b2b-accounts.ts @@ -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 { - 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() diff --git a/prisma/seed.ts b/prisma/seed.ts index 53df83e..b5982e5 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -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()