|
|
@@ -0,0 +1,198 @@
|
|
|
+import type { Queryable } from '../../../db/types.js';
|
|
|
+import type { JdProductRecord } from '../adapters/jd-product.adapter.js';
|
|
|
+import type { JdReviewRecord } from '../adapters/jd-review.adapter.js';
|
|
|
+
|
|
|
+export type SyncJobFinalStatus = 'completed' | 'partial' | 'failed';
|
|
|
+
|
|
|
+export interface SyncPersistence {
|
|
|
+ upsertProduct(workspaceId: string, product: JdProductRecord): Promise<void>;
|
|
|
+ ensureProductStub(workspaceId: string, platform: string, productId: string): Promise<void>;
|
|
|
+ upsertReviews(workspaceId: string, platform: string, productId: string, reviews: JdReviewRecord[]): Promise<number>;
|
|
|
+ setJobProgress(jobInternalId: string, progress: number): Promise<void>;
|
|
|
+ finishJob(jobInternalId: string, status: SyncJobFinalStatus, errorSummary?: string): Promise<void>;
|
|
|
+ requeueJob(jobInternalId: string, errorSummary: string): Promise<void>;
|
|
|
+ addJobEvent(input: {
|
|
|
+ jobInternalId: string;
|
|
|
+ level: 'info' | 'warning' | 'error';
|
|
|
+ eventType: string;
|
|
|
+ message: string;
|
|
|
+ details?: Record<string, unknown>;
|
|
|
+ }): Promise<void>;
|
|
|
+}
|
|
|
+
|
|
|
+function chunks<T>(values: T[], size = 100): T[][] {
|
|
|
+ const output: T[][] = [];
|
|
|
+ for (let index = 0; index < values.length; index += size) output.push(values.slice(index, index + size));
|
|
|
+ return output;
|
|
|
+}
|
|
|
+
|
|
|
+function valuesClause(rows: unknown[][]): { sql: string; values: unknown[] } {
|
|
|
+ const values: unknown[] = [];
|
|
|
+ const sql = rows.map((row) => {
|
|
|
+ const placeholders = row.map((value) => {
|
|
|
+ values.push(value);
|
|
|
+ return `$${values.length}`;
|
|
|
+ });
|
|
|
+ return `(${placeholders.join(', ')})`;
|
|
|
+ }).join(', ');
|
|
|
+ return { sql, values };
|
|
|
+}
|
|
|
+
|
|
|
+export class VocIngestionRepository implements SyncPersistence {
|
|
|
+ constructor(private readonly database: Queryable) {}
|
|
|
+
|
|
|
+ async upsertProduct(workspacePublicId: string, product: JdProductRecord): Promise<void> {
|
|
|
+ const result = await this.database.query(`
|
|
|
+ INSERT INTO voc.product (
|
|
|
+ workspace_id, platform, product_id, product_key, role, brand, title, model,
|
|
|
+ category_1, category_2, category_3, source, raw_payload
|
|
|
+ )
|
|
|
+ SELECT id, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb
|
|
|
+ FROM voc.workspace
|
|
|
+ WHERE public_id = $1 AND status = 'active'
|
|
|
+ ON CONFLICT (workspace_id, platform, product_id) DO UPDATE SET
|
|
|
+ product_key = EXCLUDED.product_key,
|
|
|
+ role = EXCLUDED.role,
|
|
|
+ brand = COALESCE(NULLIF(EXCLUDED.brand, ''), voc.product.brand),
|
|
|
+ title = COALESCE(NULLIF(EXCLUDED.title, ''), voc.product.title),
|
|
|
+ model = COALESCE(NULLIF(EXCLUDED.model, ''), voc.product.model),
|
|
|
+ category_1 = COALESCE(NULLIF(EXCLUDED.category_1, ''), voc.product.category_1),
|
|
|
+ category_2 = COALESCE(NULLIF(EXCLUDED.category_2, ''), voc.product.category_2),
|
|
|
+ category_3 = COALESCE(NULLIF(EXCLUDED.category_3, ''), voc.product.category_3),
|
|
|
+ source = EXCLUDED.source,
|
|
|
+ raw_payload = EXCLUDED.raw_payload,
|
|
|
+ updated_at = now()
|
|
|
+ RETURNING id
|
|
|
+ `, [
|
|
|
+ workspacePublicId,
|
|
|
+ product.platform,
|
|
|
+ product.productId,
|
|
|
+ product.productKey,
|
|
|
+ product.role,
|
|
|
+ product.brand,
|
|
|
+ product.title,
|
|
|
+ product.model,
|
|
|
+ product.category1,
|
|
|
+ product.category2,
|
|
|
+ product.category3,
|
|
|
+ product.source,
|
|
|
+ JSON.stringify(product.rawPayload),
|
|
|
+ ]);
|
|
|
+ if (!result.rowCount) throw new Error(`Workspace not found: ${workspacePublicId}`);
|
|
|
+ }
|
|
|
+
|
|
|
+ async ensureProductStub(workspacePublicId: string, platform: string, productId: string): Promise<void> {
|
|
|
+ const result = await this.database.query(`
|
|
|
+ INSERT INTO voc.product (workspace_id, platform, product_id, product_key, role, source)
|
|
|
+ SELECT id, $2, $3, $4, 'own', 'sync_stub'
|
|
|
+ FROM voc.workspace
|
|
|
+ WHERE public_id = $1 AND status = 'active'
|
|
|
+ ON CONFLICT (workspace_id, platform, product_id) DO NOTHING
|
|
|
+ RETURNING id
|
|
|
+ `, [workspacePublicId, platform, productId, `${platform}:${productId}`]);
|
|
|
+ if (!result.rowCount) {
|
|
|
+ const workspace = await this.database.query(
|
|
|
+ "SELECT id FROM voc.workspace WHERE public_id = $1 AND status = 'active'",
|
|
|
+ [workspacePublicId],
|
|
|
+ );
|
|
|
+ if (!workspace.rowCount) throw new Error(`Workspace not found: ${workspacePublicId}`);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async upsertReviews(
|
|
|
+ workspacePublicId: string,
|
|
|
+ platform: string,
|
|
|
+ naturalProductId: string,
|
|
|
+ reviews: JdReviewRecord[],
|
|
|
+ ): Promise<number> {
|
|
|
+ if (!reviews.length) return 0;
|
|
|
+ const product = await this.database.query<{ workspace_id: string; product_internal_id: string }>(`
|
|
|
+ SELECT product.workspace_id, product.id AS product_internal_id
|
|
|
+ FROM voc.product product
|
|
|
+ JOIN voc.workspace workspace ON workspace.id = product.workspace_id
|
|
|
+ WHERE workspace.public_id = $1 AND product.platform = $2 AND product.product_id = $3
|
|
|
+ `, [workspacePublicId, platform, naturalProductId]);
|
|
|
+ const target = product.rows[0];
|
|
|
+ if (!target) throw new Error(`Product not found: ${platform}:${naturalProductId}`);
|
|
|
+
|
|
|
+ let affected = 0;
|
|
|
+ for (const batch of chunks(reviews)) {
|
|
|
+ const values = valuesClause(batch.map((review) => [
|
|
|
+ target.workspace_id,
|
|
|
+ target.product_internal_id,
|
|
|
+ platform,
|
|
|
+ review.reviewId || null,
|
|
|
+ review.reviewKey,
|
|
|
+ review.rating,
|
|
|
+ review.content,
|
|
|
+ review.reviewDate || null,
|
|
|
+ JSON.stringify(review.rawPayload),
|
|
|
+ ]));
|
|
|
+ const result = await this.database.query(`
|
|
|
+ INSERT INTO voc.review (
|
|
|
+ workspace_id, product_id, platform, source_review_id, review_key,
|
|
|
+ rating, content, review_date, raw_payload
|
|
|
+ ) VALUES ${values.sql}
|
|
|
+ ON CONFLICT (workspace_id, platform, review_key) DO UPDATE SET
|
|
|
+ rating = EXCLUDED.rating,
|
|
|
+ content = EXCLUDED.content,
|
|
|
+ review_date = EXCLUDED.review_date,
|
|
|
+ raw_payload = EXCLUDED.raw_payload,
|
|
|
+ updated_at = now()
|
|
|
+ `, values.values);
|
|
|
+ affected += result.rowCount ?? batch.length;
|
|
|
+ }
|
|
|
+ return affected;
|
|
|
+ }
|
|
|
+
|
|
|
+ async setJobProgress(jobInternalId: string, progress: number): Promise<void> {
|
|
|
+ await this.database.query(`
|
|
|
+ UPDATE voc.sync_job
|
|
|
+ SET progress = $2, updated_at = now()
|
|
|
+ WHERE id = $1 AND status = 'processing'
|
|
|
+ `, [jobInternalId, Math.max(0, Math.min(100, Math.round(progress)))]);
|
|
|
+ }
|
|
|
+
|
|
|
+ async finishJob(jobInternalId: string, status: SyncJobFinalStatus, errorSummary = ''): Promise<void> {
|
|
|
+ await this.database.query(`
|
|
|
+ UPDATE voc.sync_job
|
|
|
+ SET status = $2,
|
|
|
+ progress = 100,
|
|
|
+ error_summary = NULLIF($3, ''),
|
|
|
+ completed_at = now(),
|
|
|
+ updated_at = now()
|
|
|
+ WHERE id = $1
|
|
|
+ `, [jobInternalId, status, errorSummary.slice(0, 1_000)]);
|
|
|
+ }
|
|
|
+
|
|
|
+ async requeueJob(jobInternalId: string, errorSummary: string): Promise<void> {
|
|
|
+ await this.database.query(`
|
|
|
+ UPDATE voc.sync_job
|
|
|
+ SET status = 'pending',
|
|
|
+ progress = 0,
|
|
|
+ worker_id = NULL,
|
|
|
+ error_summary = $2,
|
|
|
+ updated_at = now()
|
|
|
+ WHERE id = $1 AND status = 'processing'
|
|
|
+ `, [jobInternalId, errorSummary.slice(0, 1_000)]);
|
|
|
+ }
|
|
|
+
|
|
|
+ async addJobEvent(input: {
|
|
|
+ jobInternalId: string;
|
|
|
+ level: 'info' | 'warning' | 'error';
|
|
|
+ eventType: string;
|
|
|
+ message: string;
|
|
|
+ details?: Record<string, unknown>;
|
|
|
+ }): Promise<void> {
|
|
|
+ await this.database.query(`
|
|
|
+ INSERT INTO voc.sync_job_event (sync_job_id, level, event_type, message, details)
|
|
|
+ VALUES ($1, $2, $3, $4, $5::jsonb)
|
|
|
+ `, [
|
|
|
+ input.jobInternalId,
|
|
|
+ input.level,
|
|
|
+ input.eventType,
|
|
|
+ input.message.slice(0, 1_000),
|
|
|
+ JSON.stringify(input.details ?? {}),
|
|
|
+ ]);
|
|
|
+ }
|
|
|
+}
|