| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225 |
- 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<Record<string, string | null>>(`
- 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;
- }
|