import cors from 'cors'; import express, { Router, type ErrorRequestHandler, type RequestHandler } from 'express'; import type { Pool } from 'pg'; import { ZodError } from 'zod'; import type { AppConfig } from './config/env.js'; import { ApiError } from './http/api-error.js'; import { createDomesticVocRouter, type DomesticSnapshotProvider } from './modules/domestic-voc/routes.js'; import { SyncJobRepository, type SyncJobStore } from './modules/domestic-voc/repositories/sync-job.repository.js'; import { SnapshotService } from './modules/domestic-voc/services/snapshot.service.js'; import { SyncService } from './modules/domestic-voc/services/sync.service.js'; import { createAuthenticationMiddleware, createAuthenticator, WorkspaceAccessService } from './modules/saas-platform/auth.js'; import type { PlatformRepository } from './modules/saas-platform/domain.js'; import { PostgresPlatformRepository } from './modules/saas-platform/postgres-platform.repository.js'; import { createSaasPlatformRouter } from './modules/saas-platform/routes.js'; import { FmodeAiClient } from './modules/ai-gateway/client.js'; import { createAiGatewayRouter } from './modules/ai-gateway/routes.js'; import type { AiPromptConfigStore } from './modules/ai-gateway/prompt-config.repository.js'; import { createProductKnowledgeRouter } from './modules/product-knowledge/routes.js'; import type { ProductKnowledgeStore } from './modules/product-knowledge/product-knowledge.store.js'; import { InMemoryListingAiRepository } from './modules/listing-ai/repositories/in-memory-listing-ai.repository.js'; import { FmodeJdVocAiScoringProvider, FmodeListingAiScoringProvider, ListingAiService } from './modules/listing-ai/listing-ai.service.js'; import { FmodeGeminiImageReviewProvider } from './modules/listing-ai/image-review/gemini-image-review.provider.js'; import { createListingAiRouter } from './modules/listing-ai/routes.js'; import type { ListingAiRepository } from './modules/listing-ai/domain.js'; import type { CompetitorListingMonitorService } from './modules/competitor-listing-monitor/competitor-listing-monitor.service.js'; import { createCompetitorListingMonitorRouter } from './modules/competitor-listing-monitor/routes.js'; import { createCloudFunctionRouter, dispatchThroughRouter } from './cloud-functions/router.js'; import { createSpecialActionHandler } from './cloud-functions/special-actions.js'; import { FmodeVocEcommerceClient } from './modules/domestic-voc/upstream/fmode-client.js'; export function createApp(input: { config: AppConfig; pool?: Pool; parseApp?: RequestHandler; platformRepository?: PlatformRepository; jobs?: SyncJobStore; snapshot?: DomesticSnapshotProvider; healthCheck?: () => Promise<{ ready: boolean; missingObjects: string[] }>; aiPromptConfigs?: AiPromptConfigStore; productKnowledge?: ProductKnowledgeStore; listingAiRepository?: ListingAiRepository; listingAiService?: ListingAiService; competitorListingMonitor?: CompetitorListingMonitorService; }) { const app = express(); app.disable('x-powered-by'); app.use(cors({ origin(origin, callback) { if (!origin || input.config.corsOrigins.includes(origin)) return callback(null, true); return callback(new Error('Origin is not allowed')); }, credentials: true, })); app.use(express.json({ limit: '1mb' })); app.get('/health', async (_request, response) => { try { if (input.healthCheck) { const readiness = await input.healthCheck(); if (!readiness.ready) { response.status(503).json({ service: 'saas-voc-server', status: 'unavailable', database: readiness.missingObjects.length ? 'migration_required' : 'unavailable', missingTables: readiness.missingObjects, storage: input.config.storageDriver, timestamp: new Date().toISOString(), }); return; } response.json({ service: 'saas-voc-server', status: 'ok', database: 'ready', storage: input.config.storageDriver, timestamp: new Date().toISOString(), }); return; } if (!input.pool) throw new Error('Database pool is not configured'); const result = await input.pool.query>(` SELECT to_regclass('voc.workspace')::text AS workspace, to_regclass('voc.product')::text AS product, to_regclass('voc.review')::text AS review, to_regclass('voc.sync_job')::text AS sync_job, to_regclass('voc.workspace_member')::text AS workspace_member, to_regclass('voc.analysis_run')::text AS analysis_run, to_regclass('voc.action_item')::text AS action_item, to_regclass('voc.alert')::text AS alert, to_regclass('voc.audit_log')::text AS audit_log ,to_regclass('voc.listing_source_snapshot')::text AS listing_source_snapshot ,to_regclass('voc.listing_score_job')::text AS listing_score_job ,to_regclass('voc.listing_score_item')::text AS listing_score_item ,to_regclass('voc.listing_current_score')::text AS listing_current_score ,to_regclass('voc.listing_version')::text AS listing_version `); const readiness = result.rows[0] ?? {}; const missingTables = Object.entries(readiness) .filter((entry) => !entry[1]) .map((entry) => `voc.${entry[0]}`); if (missingTables.length) { response.status(503).json({ service: 'saas-voc-server', status: 'unavailable', database: 'migration_required', missingTables, timestamp: new Date().toISOString(), }); return; } response.json({ service: 'saas-voc-server', status: 'ok', database: 'ready', timestamp: new Date().toISOString(), }); } catch { response.status(503).json({ service: 'saas-voc-server', status: 'unavailable', database: 'unavailable', timestamp: new Date().toISOString(), }); } }); if (input.parseApp) app.use('/parse', input.parseApp); if (!input.platformRepository && !input.pool) throw new Error('Platform repository is not configured'); const platform = input.platformRepository ?? new PostgresPlatformRepository(input.pool!); const access = new WorkspaceAccessService(platform); const businessRouter = Router(); const authenticationMiddleware = createAuthenticationMiddleware(createAuthenticator(input.config)); app.use('/api', authenticationMiddleware); const aiClient = new FmodeAiClient(input.config.ai); const domesticGateway = new FmodeVocEcommerceClient(input.config.fmode); businessRouter.use('/ai', createAiGatewayRouter(aiClient, input.aiPromptConfigs)); const jobs = input.jobs ?? new SyncJobRepository(input.pool!); const sync = new SyncService(jobs); const snapshot = input.snapshot ?? new SnapshotService(input.pool!); businessRouter.use('/domestic-voc', createDomesticVocRouter({ jobs, sync, snapshot, catalog: platform, access, defaultWorkspaceId: input.config.auth.defaultWorkspaceId, })); businessRouter.use('/saas', createSaasPlatformRouter({ repository: platform, access })); const listingAi = input.listingAiService ?? new ListingAiService( input.listingAiRepository ?? new InMemoryListingAiRepository(), new FmodeListingAiScoringProvider(aiClient, input.config.listingAi.model), () => new Date(), input.config.listingAi.concurrency, input.config.listingAi.maxAiItemsPerJob, input.config.listingAi.jdVocAiEnabled ? new FmodeJdVocAiScoringProvider(aiClient, input.config.listingAi.jdVocAiModel) : undefined, input.config.listingAi.jdVocImageShadowEnabled ? new FmodeGeminiImageReviewProvider({ baseUrl: process.env.FMODE_LLM_BASE_URL ?? input.config.ai.baseUrl, token: process.env.FMODE_LLM_API_KEY ?? input.config.ai.token, timeoutMs: 45_000 }) : undefined, input.config.listingAi.jdVocDisplayDefault, input.config.listingAi.jdVocEnabled, ); businessRouter.use('/listing-ai', createListingAiRouter({ service: listingAi, access, audit: platform, defaultWorkspaceId: input.config.auth.defaultWorkspaceId, })); queueMicrotask(() => { void listingAi.resumePendingJobs(input.config.auth.defaultWorkspaceId).catch((error: unknown) => { console.error('[listing-ai] failed to resume persisted score jobs', error); }); }); if (input.productKnowledge) { businessRouter.use('/knowledge', createProductKnowledgeRouter({ store: input.productKnowledge, repository: platform, access, defaultWorkspaceId: input.config.auth.defaultWorkspaceId, })); } if (input.competitorListingMonitor) { businessRouter.use('/competitor-listings', createCompetitorListingMonitorRouter({ service: input.competitorListingMonitor, access, defaultWorkspaceId: input.config.auth.defaultWorkspaceId, })); } app.use('/api', businessRouter); const localCloudFunctionRouter = createCloudFunctionRouter({ defaultWorkspaceId: input.config.auth.defaultWorkspaceId, specialAction: createSpecialActionHandler({ ai: aiClient, domestic: domesticGateway }), dispatch: (request, response, actionPath, method, body, requestId) => dispatchThroughRouter(businessRouter, request, response, actionPath, method, body, requestId), }); app.use('/api/functions', localCloudFunctionRouter); // Parse REST storage has no embedded Parse Server. This compatibility mount // lets the browser's Parse SDK keep calling /parse/functions/* while the // request is still dispatched through the same allow-listed Cloud actions. if (!input.parseApp) app.use('/parse/functions', authenticationMiddleware, localCloudFunctionRouter); app.use((_request, response) => { response.status(404).json({ error: 'not_found' }); }); const errorHandler: ErrorRequestHandler = (error, _request, response, _next) => { if (error instanceof ApiError) { response.status(error.status).json({ error: error.code }); return; } if (error instanceof ZodError) { response.status(400).json({ error: 'invalid_request', issues: error.issues.map((issue) => ({ path: issue.path.join('.'), message: issue.message })), }); return; } console.error('[http] unhandled request error', error); response.status(500).json({ error: 'internal_error' }); }; app.use(errorHandler); return app; }