chore: remediate CI blockers for production readiness
This commit is contained in:
22
.env.example
22
.env.example
@@ -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
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
16
.github/workflows/deploy.yml
vendored
16
.github/workflows/deploy.yml
vendored
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()']
|
||||
|
||||
@@ -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
|
||||
|
||||
69
e2e/api/user-admin-listing-flow.spec.ts
Normal file
69
e2e/api/user-admin-listing-flow.spec.ts
Normal 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 })]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
7
libs/ai-services/tests/conftest.py
Normal file
7
libs/ai-services/tests/conftest.py
Normal file
@@ -0,0 +1,7 @@
|
||||
import os
|
||||
|
||||
|
||||
os.environ.setdefault(
|
||||
"AI_CORS_ORIGINS",
|
||||
"http://localhost:3000,http://localhost:3001",
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user