fix(api,web): runtime fixes found during E2E + DB seed repair
Some checks failed
Security Scanning / Trivy Scan — API Image (push) Failing after 53s
Security Scanning / Trivy Scan — AI Services Image (push) Has been cancelled
Security Scanning / Trivy Filesystem Scan (push) Has been cancelled
Security Scanning / Security Gate (push) Has been cancelled
Security Scanning / Trivy Scan — Web Image (push) Has started running
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 10s
CI / AI Services (Python) — Smoke (push) Failing after 5s
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 58s
Deploy / Build API Image (push) Failing after 18s
Deploy / Build Web Image (push) Failing after 7s
CI / E2E Tests (push) Has been skipped
Deploy / Build AI Services Image (push) Failing after 7s
E2E Tests / Playwright E2E (push) Failing after 16s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 4s
Deploy / Smoke Test Staging (push) Has been cancelled
Deploy / Deploy to Staging (push) Has been cancelled
Deploy / Deploy to Production (push) Has been cancelled
Deploy / Rollback Staging (push) Has been cancelled
Deploy / Smoke Test Production (push) Has been cancelled
Deploy / Rollback Production (push) Has been cancelled
Some checks failed
Security Scanning / Trivy Scan — API Image (push) Failing after 53s
Security Scanning / Trivy Scan — AI Services Image (push) Has been cancelled
Security Scanning / Trivy Filesystem Scan (push) Has been cancelled
Security Scanning / Security Gate (push) Has been cancelled
Security Scanning / Trivy Scan — Web Image (push) Has started running
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 10s
CI / AI Services (Python) — Smoke (push) Failing after 5s
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 58s
Deploy / Build API Image (push) Failing after 18s
Deploy / Build Web Image (push) Failing after 7s
CI / E2E Tests (push) Has been skipped
Deploy / Build AI Services Image (push) Failing after 7s
E2E Tests / Playwright E2E (push) Failing after 16s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 4s
Deploy / Smoke Test Staging (push) Has been cancelled
Deploy / Deploy to Staging (push) Has been cancelled
Deploy / Deploy to Production (push) Has been cancelled
Deploy / Rollback Staging (push) Has been cancelled
Deploy / Smoke Test Production (push) Has been cancelled
Deploy / Rollback Production (push) Has been cancelled
API bootstrap fixes (DI wiring):
- analytics.module: add forwardRef(() => AdminModule) to import
AI_CONFIG_PROVIDER for GetListingAiAdviceHandler + GetProjectAiAdviceHandler
- listings.module: add PaymentsModule to imports so PAYMENT_INITIATOR is
resolvable by FeatureListingHandler
- metrics.module: register 3 missing Prometheus providers that MetricsService
injects (READ_MODEL_PROJECTOR_LAG_SECONDS / REFRESH_DURATION /
RECONCILIATION_DRIFT_TOTAL) — caused boot failure previously
- get-listing-ai-advice.handler: switch LISTING_REPOSITORY import from barrel
@modules/listings to direct internal path to break circular reference that
made the symbol evaluate as undefined at decorator time
- shared.module: comment out broken EVENT_BUS / OutboxService / OutboxRelay
providers (depend on @goodgo/contracts-events workspace pkg not yet wired)
CSRF middleware:
- Rewrite exclude logic as inline path-check inside the middleware itself.
Nest 11 + path-to-regexp v8 changed how MiddlewareConsumer.exclude() matches
against forRoutes('*') — the previous string patterns silently stopped
matching, causing every POST to /auth/login to return 403 CSRF Forbidden.
Inlined exempt list strips the /api/v1 prefix and checks against a Set.
Admin revenue stats:
- admin-stats.queries: use Prisma.sql template fragments for DATE_TRUNC unit
('day'|'month'). Passing the unit as a bind parameter caused Postgres error
42803 (column must appear in GROUP BY) because the planner treats $1 as an
opaque scalar and cannot prove SELECT and GROUP BY expressions are equal.
Admin audit-log page:
- SeverityPill: add ?? 'info' fallback — backend AuditLogEntry does not
include a `severity` field, so SEVERITY_CONFIG[undefined] was undefined
and .dir threw TypeError, crashing the whole audit-log page.
DB seed fixes:
- seed.ts: replace Vietnamese enum literals ('Sổ hồng', 'Sổ đỏ') with
correct enum keys ('SO_HONG', 'SO_DO') for the LegalStatus column
- seed-industrial-parks.ts: gate the standalone main() behind
require.main === module so importing the file from seed.ts doesn't
immediately close the pg.Pool used by the orchestrator
- scripts/seed-industrial-listings.ts: restore from tmp/ stash; was missing
from scripts/ causing seed.ts import to fail at startup
- migration 20260429010000_add_property_certificate_verified: Property table
was missing the certificateVerified column required by seed + Prisma schema
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { type PrismaService } from '@modules/shared';
|
||||
import {
|
||||
type DashboardStats,
|
||||
@@ -80,7 +81,12 @@ export async function getRevenueStats(
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
const truncUnit = groupBy === 'day' ? 'day' : 'month';
|
||||
// Postgres can't prove that `DATE_TRUNC($n, ...)` in SELECT and in GROUP BY
|
||||
// are the same expression when the first argument is a bind parameter — it
|
||||
// raises "column must appear in the GROUP BY clause" (42803). Inline the
|
||||
// unit as a raw fragment instead. `groupBy` is already constrained to the
|
||||
// 'day' | 'month' union so this is safe from injection.
|
||||
const truncUnit = groupBy === 'day' ? Prisma.sql`'day'` : Prisma.sql`'month'`;
|
||||
|
||||
const rows = await prisma.$queryRaw<RevenueRawRow[]>`
|
||||
SELECT
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { forwardRef, Module } from '@nestjs/common';
|
||||
import { CqrsModule } from '@nestjs/cqrs';
|
||||
import { makeCounterProvider, makeHistogramProvider } from '@willsoto/nestjs-prometheus';
|
||||
import { AdminModule } from '@modules/admin';
|
||||
import { ListingsModule } from '@modules/listings';
|
||||
import { ProjectsModule } from '@modules/projects';
|
||||
import { GenerateReportHandler } from './application/commands/generate-report/generate-report.handler';
|
||||
@@ -84,7 +85,12 @@ const EventHandlers = [
|
||||
];
|
||||
|
||||
@Module({
|
||||
imports: [CqrsModule, forwardRef(() => ListingsModule), ProjectsModule],
|
||||
imports: [
|
||||
CqrsModule,
|
||||
forwardRef(() => ListingsModule),
|
||||
ProjectsModule,
|
||||
forwardRef(() => AdminModule), // for AI_CONFIG_PROVIDER (used by AI advice handlers)
|
||||
],
|
||||
controllers: [AnalyticsController, AvmController],
|
||||
providers: [
|
||||
// AI service client
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { HttpStatus, Inject } from '@nestjs/common';
|
||||
import { QueryBus, QueryHandler, type IQueryHandler } from '@nestjs/cqrs';
|
||||
// Direct internal path: barrel `@modules/listings` exports `ListingsModule`
|
||||
// first, which transitively imports the analytics handler back here. At
|
||||
// constructor-decorator evaluation time the barrel has not yet exported
|
||||
// `LISTING_REPOSITORY`, so DI resolves it as `undefined`.
|
||||
// eslint-disable-next-line no-restricted-imports -- circular-import workaround; see comment above
|
||||
import {
|
||||
LISTING_REPOSITORY,
|
||||
type IListingRepository,
|
||||
} from '@modules/listings';
|
||||
} from '@modules/listings/domain/repositories/listing.repository';
|
||||
import {
|
||||
AI_CONFIG_PROVIDER,
|
||||
DomainException,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { forwardRef, Module } from '@nestjs/common';
|
||||
import { CqrsModule } from '@nestjs/cqrs';
|
||||
import { MulterModule } from '@nestjs/platform-express';
|
||||
import { AnalyticsModule } from '@modules/analytics';
|
||||
import { PaymentsModule } from '@modules/payments';
|
||||
import { FeatureListingThrottlerGuard } from '@modules/shared';
|
||||
import { AdminFeatureListingHandler } from './application/commands/admin-feature-listing/admin-feature-listing.handler';
|
||||
import { BulkUpdateListingsHandler } from './application/commands/bulk-update-listings/bulk-update-listings.handler';
|
||||
@@ -68,6 +69,7 @@ const EventHandlers = [
|
||||
imports: [
|
||||
CqrsModule,
|
||||
forwardRef(() => AnalyticsModule),
|
||||
PaymentsModule, // for PAYMENT_INITIATOR (used by FeatureListingHandler)
|
||||
MulterModule.register({
|
||||
limits: { fileSize: 100 * 1024 * 1024 }, // 100 MB — per-type limits enforced by FileValidationPipe
|
||||
}),
|
||||
|
||||
@@ -31,6 +31,9 @@ import {
|
||||
SEARCH_QUERY_DURATION,
|
||||
GOODGO_WS_CONNECTED_CLIENTS,
|
||||
GOODGO_WS_MESSAGES_TOTAL,
|
||||
READ_MODEL_PROJECTOR_LAG_SECONDS,
|
||||
READ_MODEL_REFRESH_DURATION_SECONDS,
|
||||
READ_MODEL_RECONCILIATION_DRIFT_TOTAL,
|
||||
WEB_VITALS_LCP,
|
||||
WEB_VITALS_FCP,
|
||||
WEB_VITALS_CLS,
|
||||
@@ -111,6 +114,24 @@ import { HttpMetricsInterceptor } from './presentation/interceptors/http-metrics
|
||||
labelNames: ['namespace', 'event', 'direction'],
|
||||
}),
|
||||
|
||||
// ── Read-Model Metrics (RFC-003) ──
|
||||
makeGaugeProvider({
|
||||
name: READ_MODEL_PROJECTOR_LAG_SECONDS,
|
||||
help: 'Projector replication lag in seconds, by read model',
|
||||
labelNames: ['read_model'],
|
||||
}),
|
||||
makeHistogramProvider({
|
||||
name: READ_MODEL_REFRESH_DURATION_SECONDS,
|
||||
help: 'Materialized-view refresh duration in seconds',
|
||||
labelNames: ['read_model'],
|
||||
buckets: [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60],
|
||||
}),
|
||||
makeCounterProvider({
|
||||
name: READ_MODEL_RECONCILIATION_DRIFT_TOTAL,
|
||||
help: 'Drift events detected during read-model reconciliation',
|
||||
labelNames: ['read_model', 'severity'],
|
||||
}),
|
||||
|
||||
// ── Services & Interceptors ──
|
||||
MetricsService,
|
||||
HttpMetricsInterceptor,
|
||||
|
||||
@@ -8,6 +8,47 @@ const TOKEN_LENGTH = 32;
|
||||
|
||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
|
||||
/**
|
||||
* Routes that bootstrap a session (or accept beacons that can't carry a
|
||||
* CSRF header) and are therefore exempt from the double-submit check.
|
||||
* Matched against the request path with the API global prefix stripped.
|
||||
*
|
||||
* NOTE: We check inside the middleware instead of relying on
|
||||
* `MiddlewareConsumer.exclude(...)` because Nest 11 + path-to-regexp v8
|
||||
* changed how `forRoutes('*')` interacts with prefixed excludes — patterns
|
||||
* that used to work (e.g. `'auth/login'`) silently no longer match.
|
||||
*/
|
||||
const EXEMPT_POST_PATHS = new Set<string>([
|
||||
'/auth/login',
|
||||
'/auth/register',
|
||||
'/auth/refresh',
|
||||
'/auth/logout',
|
||||
'/auth/exchange-token',
|
||||
'/auth/forgot-password',
|
||||
'/auth/reset-password',
|
||||
'/web-vitals',
|
||||
]);
|
||||
|
||||
const EXEMPT_POST_PREFIXES: ReadonlyArray<string> = [
|
||||
'/payments/callback/',
|
||||
];
|
||||
|
||||
function stripApiPrefix(url: string): string {
|
||||
// Only the path matters for matching — drop the query string.
|
||||
const path = url.split('?')[0] ?? url;
|
||||
if (path.startsWith('/api/v1')) {
|
||||
return path.slice('/api/v1'.length) || '/';
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
function isExempt(method: string, url: string): boolean {
|
||||
if (method !== 'POST') return false;
|
||||
const path = stripApiPrefix(url);
|
||||
if (EXEMPT_POST_PATHS.has(path)) return true;
|
||||
return EXEMPT_POST_PREFIXES.some((prefix) => path.startsWith(prefix));
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CsrfMiddleware implements NestMiddleware {
|
||||
use(req: Request, res: Response, next: NextFunction): void {
|
||||
@@ -17,6 +58,13 @@ export class CsrfMiddleware implements NestMiddleware {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Bootstrap + sendBeacon endpoints — never check, but still plant the
|
||||
// cookie so the next request from the same client can pass validation.
|
||||
if (isExempt(req.method, req.originalUrl ?? req.url)) {
|
||||
this.ensureCsrfCookie(req, res);
|
||||
return next();
|
||||
}
|
||||
|
||||
// State-changing methods: validate the double-submit token
|
||||
const cookieToken = req.cookies?.[CSRF_COOKIE] as string | undefined;
|
||||
const headerToken = req.headers[CSRF_HEADER] as string | undefined;
|
||||
|
||||
@@ -90,14 +90,29 @@ export class SharedModule implements NestModule {
|
||||
consumer
|
||||
.apply(CsrfMiddleware)
|
||||
.exclude(
|
||||
{ path: 'payments/callback/(.*)', method: RequestMethod.POST },
|
||||
// NOTE: Nest 11 + path-to-regexp v8 matches `forRoutes('*')`
|
||||
// middleware exclude paths against the FULL request URL — i.e.
|
||||
// including the global prefix `api/v1`. Listing both forms keeps
|
||||
// the rule resilient if the prefix or matching mode changes.
|
||||
{ path: 'api/v1/payments/callback/*path', method: RequestMethod.POST },
|
||||
{ path: 'api/v1/auth/login', method: RequestMethod.POST },
|
||||
{ path: 'api/v1/auth/register', method: RequestMethod.POST },
|
||||
{ path: 'api/v1/auth/refresh', method: RequestMethod.POST },
|
||||
{ path: 'api/v1/auth/exchange-token', method: RequestMethod.POST },
|
||||
{ path: 'api/v1/auth/logout', method: RequestMethod.POST },
|
||||
{ path: 'api/v1/auth/forgot-password', method: RequestMethod.POST },
|
||||
{ path: 'api/v1/auth/reset-password', method: RequestMethod.POST },
|
||||
{ path: 'api/v1/web-vitals', method: RequestMethod.POST }, // sendBeacon cannot send CSRF headers
|
||||
// Legacy controller-relative forms (kept for older path-matching modes).
|
||||
{ path: 'auth/login', method: RequestMethod.POST },
|
||||
{ path: 'auth/register', method: RequestMethod.POST },
|
||||
{ path: 'auth/refresh', method: RequestMethod.POST },
|
||||
{ path: 'auth/exchange-token', method: RequestMethod.POST },
|
||||
{ path: 'auth/logout', method: RequestMethod.POST },
|
||||
{ path: 'api/v1/web-vitals', method: RequestMethod.POST }, // sendBeacon cannot send CSRF headers
|
||||
{ path: 'web-vitals', method: RequestMethod.POST }, // middleware exclude uses controller-relative path
|
||||
{ path: 'auth/forgot-password', method: RequestMethod.POST },
|
||||
{ path: 'auth/reset-password', method: RequestMethod.POST },
|
||||
{ path: 'web-vitals', method: RequestMethod.POST },
|
||||
{ path: 'payments/callback/*path', method: RequestMethod.POST },
|
||||
)
|
||||
.forRoutes('*');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user