|
@@ -0,0 +1,891 @@
|
|
|
|
|
+import { randomUUID } from 'node:crypto';
|
|
|
|
|
+import { ApiError } from '../../http/api-error.js';
|
|
|
|
|
+import { ParseRestClient, parseDate, parseDateIso } from '../../db/parse-rest.client.js';
|
|
|
|
|
+import { isVocSchemaReady, VOC_PARSE_CLASSES } from '../../db/parse-rest.schema.js';
|
|
|
|
|
+import type { DomesticDataset, DomesticProduct, DomesticReview } from '../../types/domestic-dataset.js';
|
|
|
|
|
+import type { JdProductRecord } from '../domestic-voc/adapters/jd-product.adapter.js';
|
|
|
|
|
+import type { JdReviewRecord } from '../domestic-voc/adapters/jd-review.adapter.js';
|
|
|
|
|
+import type {
|
|
|
|
|
+ EnqueueSyncJobInput,
|
|
|
|
|
+ SyncJobRecord,
|
|
|
|
|
+ SyncJobStore,
|
|
|
|
|
+} from '../domestic-voc/repositories/sync-job.repository.js';
|
|
|
|
|
+import type { SyncJobFinalStatus, SyncPersistence } from '../domestic-voc/repositories/voc-ingestion.repository.js';
|
|
|
|
|
+import { ParseRestSnapshotService } from '../domestic-voc/services/parse-rest-snapshot.service.js';
|
|
|
|
|
+import type {
|
|
|
|
|
+ ActionItem,
|
|
|
|
|
+ AlertItem,
|
|
|
|
|
+ AnalysisRun,
|
|
|
|
|
+ AuditEntry,
|
|
|
|
|
+ CursorPage,
|
|
|
|
|
+ DataSourceSummary,
|
|
|
|
|
+ DomesticProductDetail,
|
|
|
|
|
+ ImportBatchSummary,
|
|
|
|
|
+ PlatformRepository,
|
|
|
|
|
+ WorkspaceMember,
|
|
|
|
|
+ WorkspaceRole,
|
|
|
|
|
+ WorkspaceSummary,
|
|
|
|
|
+} from './domain.js';
|
|
|
|
|
+import { decodeCursor, pageFromSortedItems } from './pagination.js';
|
|
|
|
|
+
|
|
|
|
|
+interface WorkspaceObject {
|
|
|
|
|
+ publicId: string;
|
|
|
|
|
+ name: string;
|
|
|
|
|
+ caseName: string;
|
|
|
|
|
+ status: 'active' | 'disabled';
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface ParseRecordFields {
|
|
|
|
|
+ objectId: string;
|
|
|
|
|
+ createdAt: string;
|
|
|
|
|
+ updatedAt?: string;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface MemberObject {
|
|
|
|
|
+ naturalKey: string;
|
|
|
|
|
+ workspaceId: string;
|
|
|
|
|
+ userId: string;
|
|
|
|
|
+ email: string;
|
|
|
|
|
+ displayName: string;
|
|
|
|
|
+ role: WorkspaceRole;
|
|
|
|
|
+ status: WorkspaceMember['status'];
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface ProductObject extends Omit<DomesticProduct, 'asin'> {
|
|
|
|
|
+ naturalKey: string;
|
|
|
|
|
+ workspaceId: string;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface ReviewObject {
|
|
|
|
|
+ naturalKey: string;
|
|
|
|
|
+ workspaceId: string;
|
|
|
|
|
+ platform: string;
|
|
|
|
|
+ productId: string;
|
|
|
|
|
+ sourceReviewId?: string;
|
|
|
|
|
+ reviewKey: string;
|
|
|
|
|
+ rating?: number;
|
|
|
|
|
+ content: string;
|
|
|
|
|
+ reviewDate?: unknown;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface RelationObject {
|
|
|
|
|
+ workspaceId: string;
|
|
|
|
|
+ platform: string;
|
|
|
|
|
+ relationKey: string;
|
|
|
|
|
+ ownProductKey: string;
|
|
|
|
|
+ ownProductId: string;
|
|
|
|
|
+ competitorProductKey: string;
|
|
|
|
|
+ competitorProductId: string;
|
|
|
|
|
+ competitorBrand: string;
|
|
|
|
|
+ category: string;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface SyncJobObject extends ParseRecordFields {
|
|
|
|
|
+ publicId: string;
|
|
|
|
|
+ workspaceId: string;
|
|
|
|
|
+ platform: string;
|
|
|
|
|
+ idempotencyKey: string;
|
|
|
|
|
+ status: string;
|
|
|
|
|
+ scopes: string[];
|
|
|
|
|
+ productIds: string[];
|
|
|
|
|
+ progress: number;
|
|
|
|
|
+ attempts: number;
|
|
|
|
|
+ maxAttempts: number;
|
|
|
|
|
+ workerId?: string | null;
|
|
|
|
|
+ errorSummary?: string | null;
|
|
|
|
|
+ requestedAt: unknown;
|
|
|
|
|
+ startedAt?: unknown;
|
|
|
|
|
+ completedAt?: unknown;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface SourceObject {
|
|
|
|
|
+ workspaceId: string;
|
|
|
|
|
+ platform: string;
|
|
|
|
|
+ connectionKind: string;
|
|
|
|
|
+ status: string;
|
|
|
|
|
+ metadata?: { credentialStorage?: string };
|
|
|
|
|
+ lastCheckedAt?: unknown;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface ImportObject {
|
|
|
|
|
+ publicId: string;
|
|
|
|
|
+ workspaceId: string;
|
|
|
|
|
+ platform: string;
|
|
|
|
|
+ sourceKind: string;
|
|
|
|
|
+ sourceFile?: string;
|
|
|
|
|
+ status: string;
|
|
|
|
|
+ totalRows: number;
|
|
|
|
|
+ successRows: number;
|
|
|
|
|
+ failedRows: number;
|
|
|
|
|
+ completedAt?: unknown;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface AnalysisObject extends ParseRecordFields {
|
|
|
|
|
+ publicId: string;
|
|
|
|
|
+ workspaceId: string;
|
|
|
|
|
+ analysisType: AnalysisRun['analysisType'];
|
|
|
|
|
+ targetKind: AnalysisRun['targetKind'];
|
|
|
|
|
+ targetKey: string;
|
|
|
|
|
+ status: AnalysisRun['status'];
|
|
|
|
|
+ input: Record<string, unknown>;
|
|
|
|
|
+ result?: Record<string, unknown>;
|
|
|
|
|
+ evidenceCount: number;
|
|
|
|
|
+ requestedBy: string;
|
|
|
|
|
+ errorSummary?: string;
|
|
|
|
|
+ requestedAt: unknown;
|
|
|
|
|
+ startedAt?: unknown;
|
|
|
|
|
+ completedAt?: unknown;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface ActionObject extends ParseRecordFields {
|
|
|
|
|
+ publicId: string;
|
|
|
|
|
+ workspaceId: string;
|
|
|
|
|
+ actionType: ActionItem['actionType'];
|
|
|
|
|
+ title: string;
|
|
|
|
|
+ description: string;
|
|
|
|
|
+ priority: ActionItem['priority'];
|
|
|
|
|
+ status: ActionItem['status'];
|
|
|
|
|
+ productKey?: string | null;
|
|
|
|
|
+ assigneeUserId?: string | null;
|
|
|
|
|
+ dueAt?: unknown;
|
|
|
|
|
+ createdBy: string;
|
|
|
|
|
+ completedAt?: unknown;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface AlertObject extends ParseRecordFields {
|
|
|
|
|
+ publicId: string;
|
|
|
|
|
+ workspaceId: string;
|
|
|
|
|
+ alertType: AlertItem['alertType'];
|
|
|
|
|
+ severity: AlertItem['severity'];
|
|
|
|
|
+ status: AlertItem['status'];
|
|
|
|
|
+ productKey?: string | null;
|
|
|
|
|
+ title: string;
|
|
|
|
|
+ summary: string;
|
|
|
|
|
+ evidence: unknown[];
|
|
|
|
|
+ detectedAt: unknown;
|
|
|
|
|
+ acknowledgedBy?: string;
|
|
|
|
|
+ acknowledgedAt?: unknown;
|
|
|
|
|
+ resolvedAt?: unknown;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface AuditObject {
|
|
|
|
|
+ publicId: string;
|
|
|
|
|
+ workspaceId: string;
|
|
|
|
|
+ actorUserId: string;
|
|
|
|
|
+ action: string;
|
|
|
|
|
+ entityType: string;
|
|
|
|
|
+ entityId?: string;
|
|
|
|
|
+ metadata: Record<string, unknown>;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface JobEventObject {
|
|
|
|
|
+ publicId: string;
|
|
|
|
|
+ workspaceId: string;
|
|
|
|
|
+ jobPublicId: string;
|
|
|
|
|
+ level: string;
|
|
|
|
|
+ eventType: string;
|
|
|
|
|
+ message: string;
|
|
|
|
|
+ details: Record<string, unknown>;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export interface ClaimedParseSyncJob {
|
|
|
|
|
+ internalId: string;
|
|
|
|
|
+ publicId: string;
|
|
|
|
|
+ workspaceId: string;
|
|
|
|
|
+ platform: string;
|
|
|
|
|
+ scopes: string[];
|
|
|
|
|
+ productIds: string[];
|
|
|
|
|
+ attempts: number;
|
|
|
|
|
+ maxAttempts: number;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function key(...parts: string[]): string {
|
|
|
|
|
+ return parts.map((part) => encodeURIComponent(part)).join('|');
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function nullableDate(value: unknown): string | null {
|
|
|
|
|
+ return parseDateIso(value);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function requiredDate(value: unknown, fallback: string): string {
|
|
|
|
|
+ return nullableDate(value) ?? fallback;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function paginate<T>(items: T[], limit: number, cursor: string | null, getId: (item: T) => string): CursorPage<T> {
|
|
|
|
|
+ const decoded = decodeCursor(cursor);
|
|
|
|
|
+ if (cursor && !decoded) throw new ApiError(400, 'invalid_cursor');
|
|
|
|
|
+ const index = decoded ? items.findIndex((item) => getId(item) === decoded.id) : -1;
|
|
|
|
|
+ if (decoded && index < 0) throw new ApiError(400, 'invalid_cursor');
|
|
|
|
|
+ return pageFromSortedItems(items.slice(index + 1, index + limit + 2), limit, getId);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function mapReview(review: ReviewObject): DomesticReview {
|
|
|
|
|
+ const value: DomesticReview = {
|
|
|
|
|
+ productId: review.productId,
|
|
|
|
|
+ reviewId: review.sourceReviewId || review.reviewKey,
|
|
|
|
|
+ rating: Number(review.rating ?? 0),
|
|
|
|
|
+ content: review.content,
|
|
|
|
|
+ };
|
|
|
|
|
+ const date = nullableDate(review.reviewDate);
|
|
|
|
|
+ return date ? { ...value, reviewDate: date } : value;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export class ParseRestVocRepository implements PlatformRepository, SyncJobStore, SyncPersistence {
|
|
|
|
|
+ private readonly snapshot: ParseRestSnapshotService;
|
|
|
|
|
+
|
|
|
|
|
+ constructor(private readonly client: ParseRestClient) {
|
|
|
|
|
+ this.snapshot = new ParseRestSnapshotService(client);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async health(): Promise<{ ready: boolean; missingClasses: string[] }> {
|
|
|
|
|
+ if (!await this.client.health()) return { ready: false, missingClasses: [] };
|
|
|
|
|
+ const names = (await this.client.schemas()).map((schema) => schema.className);
|
|
|
|
|
+ const ready = isVocSchemaReady(names);
|
|
|
|
|
+ const available = new Set(names);
|
|
|
|
|
+ const required = Object.values(VOC_PARSE_CLASSES);
|
|
|
|
|
+ return { ready, missingClasses: ready ? [] : required.filter((name) => !available.has(name)) };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async getSnapshot(workspaceId: string, platform: string): Promise<DomesticDataset | null> {
|
|
|
|
|
+ return this.snapshot.getSnapshot(workspaceId, platform);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async getDatasetSnapshot(workspaceId: string, platform: string): Promise<DomesticDataset | null> {
|
|
|
|
|
+ return this.getSnapshot(workspaceId, platform);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async bootstrapAdmin(workspaceId: string, principal: { userId: string; email: string; displayName: string }): Promise<void> {
|
|
|
|
|
+ const workspace = await this.client.findOne<WorkspaceObject>(VOC_PARSE_CLASSES.workspace, { publicId: workspaceId });
|
|
|
|
|
+ if (!workspace) return;
|
|
|
|
|
+ await this.upsertMember({
|
|
|
|
|
+ workspaceId,
|
|
|
|
|
+ userId: principal.userId,
|
|
|
|
|
+ email: principal.email,
|
|
|
|
|
+ displayName: principal.displayName,
|
|
|
|
|
+ role: 'owner',
|
|
|
|
|
+ status: 'active',
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async listWorkspaces(userId: string): Promise<WorkspaceSummary[]> {
|
|
|
|
|
+ const memberships = await this.client.findAll<MemberObject>(VOC_PARSE_CLASSES.workspaceMember, {
|
|
|
|
|
+ userId,
|
|
|
|
|
+ status: 'active',
|
|
|
|
|
+ });
|
|
|
|
|
+ const output: WorkspaceSummary[] = [];
|
|
|
|
|
+ for (const membership of memberships) {
|
|
|
|
|
+ const workspace = await this.client.findOne<WorkspaceObject>(VOC_PARSE_CLASSES.workspace, {
|
|
|
|
|
+ publicId: membership.workspaceId,
|
|
|
|
|
+ });
|
|
|
|
|
+ if (workspace) output.push({
|
|
|
|
|
+ id: workspace.publicId,
|
|
|
|
|
+ name: workspace.name,
|
|
|
|
|
+ caseName: workspace.caseName,
|
|
|
|
|
+ status: workspace.status,
|
|
|
|
|
+ role: membership.role,
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ return output.sort((left, right) => left.name.localeCompare(right.name));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async getMembership(workspaceId: string, userId: string): Promise<WorkspaceMember | null> {
|
|
|
|
|
+ const member = await this.client.findOne<MemberObject>(VOC_PARSE_CLASSES.workspaceMember, {
|
|
|
|
|
+ naturalKey: key(workspaceId, userId),
|
|
|
|
|
+ });
|
|
|
|
|
+ return member ? this.mapMember(member) : null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async listMembers(workspaceId: string): Promise<WorkspaceMember[]> {
|
|
|
|
|
+ const members = await this.client.findAll<MemberObject>(VOC_PARSE_CLASSES.workspaceMember, { workspaceId });
|
|
|
|
|
+ return members.map((member) => this.mapMember(member)).sort((left, right) => left.createdAt.localeCompare(right.createdAt));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async countActiveOwners(workspaceId: string): Promise<number> {
|
|
|
|
|
+ return this.client.count(VOC_PARSE_CLASSES.workspaceMember, { workspaceId, role: 'owner', status: 'active' });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async upsertMember(input: {
|
|
|
|
|
+ workspaceId: string;
|
|
|
|
|
+ userId: string;
|
|
|
|
|
+ email: string;
|
|
|
|
|
+ displayName: string;
|
|
|
|
|
+ role: WorkspaceRole;
|
|
|
|
|
+ status: WorkspaceMember['status'];
|
|
|
|
|
+ }): Promise<WorkspaceMember | null> {
|
|
|
|
|
+ const workspace = await this.client.findOne(VOC_PARSE_CLASSES.workspace, { publicId: input.workspaceId });
|
|
|
|
|
+ if (!workspace) return null;
|
|
|
|
|
+ const naturalKey = key(input.workspaceId, input.userId);
|
|
|
|
|
+ const existing = await this.client.findOne<MemberObject>(VOC_PARSE_CLASSES.workspaceMember, { naturalKey });
|
|
|
|
|
+ const body = { naturalKey, ...input };
|
|
|
|
|
+ if (existing) {
|
|
|
|
|
+ const result = await this.client.update(VOC_PARSE_CLASSES.workspaceMember, existing.objectId, body);
|
|
|
|
|
+ return this.mapMember({ ...existing, ...body, updatedAt: result.updatedAt });
|
|
|
|
|
+ }
|
|
|
|
|
+ const created = await this.client.create(VOC_PARSE_CLASSES.workspaceMember, body);
|
|
|
|
|
+ return this.mapMember({ ...body, ...created });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async listProducts(input: {
|
|
|
|
|
+ workspaceId: string;
|
|
|
|
|
+ platform: string;
|
|
|
|
|
+ limit: number;
|
|
|
|
|
+ cursor: string | null;
|
|
|
|
|
+ search: string;
|
|
|
|
|
+ role?: DomesticProduct['role'];
|
|
|
|
|
+ category: string;
|
|
|
|
|
+ }): Promise<CursorPage<DomesticProduct>> {
|
|
|
|
|
+ const search = input.search.toLocaleLowerCase();
|
|
|
|
|
+ const products = (await this.client.findAll<ProductObject>(VOC_PARSE_CLASSES.product, {
|
|
|
|
|
+ workspaceId: input.workspaceId,
|
|
|
|
|
+ platform: input.platform,
|
|
|
|
|
+ source: { $nin: ['relation_stub', 'sync_stub'] },
|
|
|
|
|
+ }))
|
|
|
|
|
+ .filter((product) => !input.role || product.role === input.role)
|
|
|
|
|
+ .filter((product) => !input.category || [product.category1, product.category2, product.category3].includes(input.category))
|
|
|
|
|
+ .filter((product) => !search || [product.productId, product.model, product.title, product.brand]
|
|
|
|
|
+ .some((value) => value.toLocaleLowerCase().includes(search)))
|
|
|
|
|
+ .map((product) => this.mapProduct(product))
|
|
|
|
|
+ .sort((left, right) => left.productKey.localeCompare(right.productKey));
|
|
|
|
|
+ return paginate(products, input.limit, input.cursor, (product) => product.productKey);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async getProduct(workspaceId: string, platform: string, productId: string): Promise<DomesticProductDetail | null> {
|
|
|
|
|
+ const product = await this.client.findOne<ProductObject>(VOC_PARSE_CLASSES.product, { workspaceId, platform, productId });
|
|
|
|
|
+ if (!product) return null;
|
|
|
|
|
+ const reviews = await this.client.findAll<ReviewObject>(VOC_PARSE_CLASSES.review, { workspaceId, platform, productId });
|
|
|
|
|
+ return {
|
|
|
|
|
+ ...this.mapProduct(product),
|
|
|
|
|
+ reviews: {
|
|
|
|
|
+ count: reviews.length,
|
|
|
|
|
+ averageRating: reviews.length
|
|
|
|
|
+ ? reviews.reduce((total, review) => total + Number(review.rating ?? 0), 0) / reviews.length
|
|
|
|
|
+ : 0,
|
|
|
|
|
+ },
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async listReviews(input: { workspaceId: string; platform: string; productId: string; limit: number; cursor: string | null }): Promise<CursorPage<DomesticReview>> {
|
|
|
|
|
+ const reviews = (await this.client.findAll<ReviewObject>(VOC_PARSE_CLASSES.review, {
|
|
|
|
|
+ workspaceId: input.workspaceId,
|
|
|
|
|
+ platform: input.platform,
|
|
|
|
|
+ productId: input.productId,
|
|
|
|
|
+ })).map(mapReview).sort((left, right) => left.reviewId.localeCompare(right.reviewId));
|
|
|
|
|
+ return paginate(reviews, input.limit, input.cursor, (review) => review.reviewId);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async listRelations(input: { workspaceId: string; platform: string; limit: number; cursor: string | null }) {
|
|
|
|
|
+ const relations = (await this.client.findAll<RelationObject>(VOC_PARSE_CLASSES.productRelation, {
|
|
|
|
|
+ workspaceId: input.workspaceId,
|
|
|
|
|
+ platform: input.platform,
|
|
|
|
|
+ })).map((relation) => ({
|
|
|
|
|
+ relationKey: relation.relationKey,
|
|
|
|
|
+ ownProductKey: relation.ownProductKey,
|
|
|
|
|
+ ownProductId: relation.ownProductId,
|
|
|
|
|
+ competitorProductKey: relation.competitorProductKey,
|
|
|
|
|
+ competitorProductId: relation.competitorProductId,
|
|
|
|
|
+ competitorBrand: relation.competitorBrand,
|
|
|
|
|
+ category: relation.category,
|
|
|
|
|
+ })).sort((left, right) => left.relationKey.localeCompare(right.relationKey));
|
|
|
|
|
+ return paginate(relations, input.limit, input.cursor, (relation) => relation.relationKey);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async enqueue(input: EnqueueSyncJobInput): Promise<SyncJobRecord | null> {
|
|
|
|
|
+ const workspace = await this.client.findOne<WorkspaceObject>(VOC_PARSE_CLASSES.workspace, {
|
|
|
|
|
+ publicId: input.workspaceId,
|
|
|
|
|
+ status: 'active',
|
|
|
|
|
+ });
|
|
|
|
|
+ if (!workspace) return null;
|
|
|
|
|
+ const existing = await this.client.findOne<SyncJobObject>(VOC_PARSE_CLASSES.syncJob, {
|
|
|
|
|
+ workspaceId: input.workspaceId,
|
|
|
|
|
+ idempotencyKey: input.idempotencyKey,
|
|
|
|
|
+ });
|
|
|
|
|
+ if (existing) return this.mapJob(existing);
|
|
|
|
|
+ const timestamp = new Date();
|
|
|
|
|
+ const body = {
|
|
|
|
|
+ publicId: input.publicId,
|
|
|
|
|
+ workspaceId: input.workspaceId,
|
|
|
|
|
+ platform: input.platform,
|
|
|
|
|
+ idempotencyKey: input.idempotencyKey,
|
|
|
|
|
+ status: 'pending',
|
|
|
|
|
+ scopes: input.scopes,
|
|
|
|
|
+ productIds: input.productIds,
|
|
|
|
|
+ progress: 0,
|
|
|
|
|
+ attempts: 0,
|
|
|
|
|
+ maxAttempts: 3,
|
|
|
|
|
+ requestedAt: parseDate(timestamp),
|
|
|
|
|
+ };
|
|
|
|
|
+ const created = await this.client.create(VOC_PARSE_CLASSES.syncJob, body);
|
|
|
|
|
+ return this.mapJob({ ...body, ...created });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async findByPublicId(publicId: string): Promise<SyncJobRecord | null> {
|
|
|
|
|
+ const job = await this.client.findOne<SyncJobObject>(VOC_PARSE_CLASSES.syncJob, { publicId });
|
|
|
|
|
+ return job ? this.mapJob(job) : null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async retry(workspaceId: string, publicId: string): Promise<SyncJobRecord | null> {
|
|
|
|
|
+ const job = await this.client.findOne<SyncJobObject>(VOC_PARSE_CLASSES.syncJob, { workspaceId, publicId });
|
|
|
|
|
+ if (!job || !['partial', 'failed', 'cancelled'].includes(job.status)) return null;
|
|
|
|
|
+ const patch = {
|
|
|
|
|
+ status: 'pending', progress: 0, attempts: 0, workerId: null, errorSummary: null,
|
|
|
|
|
+ requestedAt: parseDate(new Date()), startedAt: null, completedAt: null,
|
|
|
|
|
+ };
|
|
|
|
|
+ const updated = await this.client.update(VOC_PARSE_CLASSES.syncJob, job.objectId, patch);
|
|
|
|
|
+ await this.createJobEvent(workspaceId, publicId, 'info', 'sync_manually_retried', 'Sync job queued for manual retry', { manual: true });
|
|
|
|
|
+ return this.mapJob({ ...job, ...patch, updatedAt: updated.updatedAt });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async cancel(workspaceId: string, publicId: string): Promise<SyncJobRecord | null> {
|
|
|
|
|
+ const job = await this.client.findOne<SyncJobObject>(VOC_PARSE_CLASSES.syncJob, { workspaceId, publicId });
|
|
|
|
|
+ if (!job || job.status !== 'pending') return null;
|
|
|
|
|
+ const patch = { status: 'cancelled', workerId: null, completedAt: parseDate(new Date()) };
|
|
|
|
|
+ const updated = await this.client.update(VOC_PARSE_CLASSES.syncJob, job.objectId, patch);
|
|
|
|
|
+ await this.createJobEvent(workspaceId, publicId, 'info', 'sync_manually_cancelled', 'Pending sync job cancelled', { manual: true });
|
|
|
|
|
+ return this.mapJob({ ...job, ...patch, updatedAt: updated.updatedAt });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async listJobs(input: { workspaceId: string; limit: number; cursor: string | null; status: string }) {
|
|
|
|
|
+ const jobs = (await this.client.findAll<SyncJobObject>(VOC_PARSE_CLASSES.syncJob, { workspaceId: input.workspaceId }))
|
|
|
|
|
+ .filter((job) => !input.status || job.status === input.status)
|
|
|
|
|
+ .map((job) => this.mapJob(job))
|
|
|
|
|
+ .sort((left, right) => right.requestedAt.localeCompare(left.requestedAt));
|
|
|
|
|
+ return paginate(jobs, input.limit, input.cursor, (job) => job.id);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async listJobEvents(workspaceId: string, jobId: string) {
|
|
|
|
|
+ const events = await this.client.findAll<JobEventObject>(VOC_PARSE_CLASSES.syncJobEvent, {
|
|
|
|
|
+ workspaceId,
|
|
|
|
|
+ jobPublicId: jobId,
|
|
|
|
|
+ });
|
|
|
|
|
+ return events.sort((left, right) => left.createdAt.localeCompare(right.createdAt)).map((event) => ({
|
|
|
|
|
+ id: event.publicId,
|
|
|
|
|
+ level: event.level,
|
|
|
|
|
+ type: event.eventType,
|
|
|
|
|
+ message: event.message,
|
|
|
|
|
+ details: event.details ?? {},
|
|
|
|
|
+ createdAt: event.createdAt,
|
|
|
|
|
+ }));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async listDataSources(workspaceId: string): Promise<DataSourceSummary[]> {
|
|
|
|
|
+ const sources = await this.client.findAll<SourceObject>(VOC_PARSE_CLASSES.sourceConnection, { workspaceId });
|
|
|
|
|
+ return sources.map((source) => ({
|
|
|
|
|
+ id: source.objectId,
|
|
|
|
|
+ workspaceId: source.workspaceId,
|
|
|
|
|
+ platform: source.platform,
|
|
|
|
|
+ kind: source.connectionKind,
|
|
|
|
|
+ status: source.status,
|
|
|
|
|
+ lastCheckedAt: nullableDate(source.lastCheckedAt),
|
|
|
|
|
+ credentialStorage: source.metadata?.credentialStorage === 'external_secret' ? 'external_secret' : 'environment',
|
|
|
|
|
+ }));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async listImports(input: { workspaceId: string; limit: number; cursor: string | null }): Promise<CursorPage<ImportBatchSummary>> {
|
|
|
|
|
+ const imports = (await this.client.findAll<ImportObject>(VOC_PARSE_CLASSES.importBatch, { workspaceId: input.workspaceId }))
|
|
|
|
|
+ .map((item): ImportBatchSummary => ({
|
|
|
|
|
+ id: item.publicId,
|
|
|
|
|
+ workspaceId: item.workspaceId,
|
|
|
|
|
+ platform: item.platform,
|
|
|
|
|
+ sourceKind: item.sourceKind,
|
|
|
|
|
+ sourceFile: item.sourceFile ?? null,
|
|
|
|
|
+ status: item.status,
|
|
|
|
|
+ totalRows: item.totalRows,
|
|
|
|
|
+ successRows: item.successRows,
|
|
|
|
|
+ failedRows: item.failedRows,
|
|
|
|
|
+ createdAt: item.createdAt,
|
|
|
|
|
+ completedAt: nullableDate(item.completedAt),
|
|
|
|
|
+ })).sort((left, right) => right.createdAt.localeCompare(left.createdAt));
|
|
|
|
|
+ return paginate(imports, input.limit, input.cursor, (item) => item.id);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async listAnalyses(input: { workspaceId: string; limit: number; cursor: string | null; status: string }) {
|
|
|
|
|
+ const items = (await this.client.findAll<AnalysisObject>(VOC_PARSE_CLASSES.analysisRun, { workspaceId: input.workspaceId }))
|
|
|
|
|
+ .filter((item) => !input.status || item.status === input.status)
|
|
|
|
|
+ .map((item) => this.mapAnalysis(item))
|
|
|
|
|
+ .sort((left, right) => right.requestedAt.localeCompare(left.requestedAt));
|
|
|
|
|
+ return paginate(items, input.limit, input.cursor, (item) => item.id);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async createAnalysis(input: Omit<AnalysisRun, 'status' | 'result' | 'evidenceCount' | 'errorSummary' | 'requestedAt' | 'startedAt' | 'completedAt'>): Promise<AnalysisRun> {
|
|
|
|
|
+ const requestedAt = new Date();
|
|
|
|
|
+ const body = {
|
|
|
|
|
+ publicId: input.id,
|
|
|
|
|
+ workspaceId: input.workspaceId,
|
|
|
|
|
+ analysisType: input.analysisType,
|
|
|
|
|
+ targetKind: input.targetKind,
|
|
|
|
|
+ targetKey: input.targetKey,
|
|
|
|
|
+ status: 'pending' as const,
|
|
|
|
|
+ input: input.input,
|
|
|
|
|
+ evidenceCount: 0,
|
|
|
|
|
+ requestedBy: input.requestedBy,
|
|
|
|
|
+ requestedAt: parseDate(requestedAt),
|
|
|
|
|
+ };
|
|
|
|
|
+ const created = await this.client.create(VOC_PARSE_CLASSES.analysisRun, body);
|
|
|
|
|
+ return this.mapAnalysis({ ...body, ...created });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async listActions(input: { workspaceId: string; limit: number; cursor: string | null; status: string }) {
|
|
|
|
|
+ const items = (await this.client.findAll<ActionObject>(VOC_PARSE_CLASSES.actionItem, { workspaceId: input.workspaceId }))
|
|
|
|
|
+ .filter((item) => !input.status || item.status === input.status)
|
|
|
|
|
+ .map((item) => this.mapAction(item))
|
|
|
|
|
+ .sort((left, right) => right.createdAt.localeCompare(left.createdAt));
|
|
|
|
|
+ return paginate(items, input.limit, input.cursor, (item) => item.id);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async createAction(input: Omit<ActionItem, 'completedAt' | 'createdAt' | 'updatedAt'>): Promise<ActionItem> {
|
|
|
|
|
+ const body = {
|
|
|
|
|
+ publicId: input.id,
|
|
|
|
|
+ workspaceId: input.workspaceId,
|
|
|
|
|
+ actionType: input.actionType,
|
|
|
|
|
+ title: input.title,
|
|
|
|
|
+ description: input.description,
|
|
|
|
|
+ priority: input.priority,
|
|
|
|
|
+ status: input.status,
|
|
|
|
|
+ productKey: input.productKey,
|
|
|
|
|
+ assigneeUserId: input.assigneeUserId,
|
|
|
|
|
+ dueAt: input.dueAt ? parseDate(input.dueAt) : null,
|
|
|
|
|
+ createdBy: input.createdBy,
|
|
|
|
|
+ completedAt: input.status === 'completed' ? parseDate(new Date()) : null,
|
|
|
|
|
+ };
|
|
|
|
|
+ const created = await this.client.create(VOC_PARSE_CLASSES.actionItem, body);
|
|
|
|
|
+ return this.mapAction({ ...body, ...created });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async updateAction(workspaceId: string, id: string, patch: Partial<Pick<ActionItem, 'title' | 'description' | 'priority' | 'status' | 'assigneeUserId' | 'dueAt'>>): Promise<ActionItem | null> {
|
|
|
|
|
+ const action = await this.client.findOne<ActionObject>(VOC_PARSE_CLASSES.actionItem, { workspaceId, publicId: id });
|
|
|
|
|
+ if (!action) return null;
|
|
|
|
|
+ const body: Record<string, unknown> = { ...patch };
|
|
|
|
|
+ if ('dueAt' in patch) body.dueAt = patch.dueAt ? parseDate(patch.dueAt) : null;
|
|
|
|
|
+ if (patch.status) body.completedAt = patch.status === 'completed' ? parseDate(new Date()) : null;
|
|
|
|
|
+ const result = await this.client.update(VOC_PARSE_CLASSES.actionItem, action.objectId, body);
|
|
|
|
|
+ return this.mapAction({ ...action, ...body, updatedAt: result.updatedAt } as ActionObject & typeof action);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async listAlerts(input: { workspaceId: string; limit: number; cursor: string | null; status: string }) {
|
|
|
|
|
+ const items = (await this.client.findAll<AlertObject>(VOC_PARSE_CLASSES.alert, { workspaceId: input.workspaceId }))
|
|
|
|
|
+ .filter((item) => !input.status || item.status === input.status)
|
|
|
|
|
+ .map((item) => this.mapAlert(item))
|
|
|
|
|
+ .sort((left, right) => right.detectedAt.localeCompare(left.detectedAt));
|
|
|
|
|
+ return paginate(items, input.limit, input.cursor, (item) => item.id);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async createAlert(input: Omit<AlertItem, 'detectedAt' | 'acknowledgedBy' | 'acknowledgedAt' | 'resolvedAt'>): Promise<AlertItem> {
|
|
|
|
|
+ const body = {
|
|
|
|
|
+ publicId: input.id,
|
|
|
|
|
+ workspaceId: input.workspaceId,
|
|
|
|
|
+ alertType: input.alertType,
|
|
|
|
|
+ severity: input.severity,
|
|
|
|
|
+ status: input.status,
|
|
|
|
|
+ productKey: input.productKey,
|
|
|
|
|
+ title: input.title,
|
|
|
|
|
+ summary: input.summary,
|
|
|
|
|
+ evidence: input.evidence,
|
|
|
|
|
+ detectedAt: parseDate(new Date()),
|
|
|
|
|
+ };
|
|
|
|
|
+ const created = await this.client.create(VOC_PARSE_CLASSES.alert, body);
|
|
|
|
|
+ return this.mapAlert({ ...body, ...created });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async updateAlert(workspaceId: string, id: string, patch: { status: AlertItem['status']; actorUserId: string }): Promise<AlertItem | null> {
|
|
|
|
|
+ const alert = await this.client.findOne<AlertObject>(VOC_PARSE_CLASSES.alert, { workspaceId, publicId: id });
|
|
|
|
|
+ if (!alert) return null;
|
|
|
|
|
+ const timestamp = parseDate(new Date());
|
|
|
|
|
+ const body = {
|
|
|
|
|
+ status: patch.status,
|
|
|
|
|
+ ...(patch.status === 'acknowledged' ? { acknowledgedBy: patch.actorUserId, acknowledgedAt: timestamp } : {}),
|
|
|
|
|
+ ...(patch.status === 'resolved' ? { resolvedAt: timestamp } : {}),
|
|
|
|
|
+ };
|
|
|
|
|
+ const result = await this.client.update(VOC_PARSE_CLASSES.alert, alert.objectId, body);
|
|
|
|
|
+ return this.mapAlert({ ...alert, ...body, updatedAt: result.updatedAt });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async listAudit(input: { workspaceId: string; limit: number; cursor: string | null }) {
|
|
|
|
|
+ const items = (await this.client.findAll<AuditObject>(VOC_PARSE_CLASSES.auditLog, { workspaceId: input.workspaceId }))
|
|
|
|
|
+ .map((item): AuditEntry => ({
|
|
|
|
|
+ id: item.publicId,
|
|
|
|
|
+ workspaceId: item.workspaceId,
|
|
|
|
|
+ actorUserId: item.actorUserId,
|
|
|
|
|
+ action: item.action,
|
|
|
|
|
+ entityType: item.entityType,
|
|
|
|
|
+ entityId: item.entityId ?? null,
|
|
|
|
|
+ metadata: item.metadata ?? {},
|
|
|
|
|
+ createdAt: item.createdAt,
|
|
|
|
|
+ })).sort((left, right) => right.createdAt.localeCompare(left.createdAt));
|
|
|
|
|
+ return paginate(items, input.limit, input.cursor, (item) => item.id);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async appendAudit(input: Omit<AuditEntry, 'id' | 'createdAt'>): Promise<void> {
|
|
|
|
|
+ await this.client.create(VOC_PARSE_CLASSES.auditLog, {
|
|
|
|
|
+ publicId: randomUUID(),
|
|
|
|
|
+ workspaceId: input.workspaceId,
|
|
|
|
|
+ actorUserId: input.actorUserId,
|
|
|
|
|
+ action: input.action,
|
|
|
|
|
+ entityType: input.entityType,
|
|
|
|
|
+ entityId: input.entityId,
|
|
|
|
|
+ metadata: input.metadata,
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async recoverStaleJobs(staleAfterMs: number): Promise<number> {
|
|
|
|
|
+ const cutoff = new Date(Date.now() - staleAfterMs);
|
|
|
|
|
+ const jobs = await this.client.findAll<SyncJobObject>(VOC_PARSE_CLASSES.syncJob, {
|
|
|
|
|
+ status: 'processing',
|
|
|
|
|
+ updatedAt: { $lt: parseDate(cutoff) },
|
|
|
|
|
+ });
|
|
|
|
|
+ for (const job of jobs) {
|
|
|
|
|
+ const retry = job.attempts < job.maxAttempts;
|
|
|
|
|
+ await this.client.update(VOC_PARSE_CLASSES.syncJob, job.objectId, {
|
|
|
|
|
+ status: retry ? 'pending' : 'failed',
|
|
|
|
|
+ workerId: null,
|
|
|
|
|
+ errorSummary: 'Worker interrupted; stale job recovered',
|
|
|
|
|
+ completedAt: retry ? null : parseDate(new Date()),
|
|
|
|
|
+ });
|
|
|
|
|
+ await this.createJobEvent(job.workspaceId, job.publicId, retry ? 'warning' : 'error', 'sync_stale_recovered',
|
|
|
|
|
+ retry ? 'Interrupted sync job returned to the queue' : 'Interrupted sync job exhausted retry attempts',
|
|
|
|
|
+ { status: retry ? 'pending' : 'failed' });
|
|
|
|
|
+ }
|
|
|
|
|
+ return jobs.length;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async claimNextJob(workerId: string): Promise<ClaimedParseSyncJob | null> {
|
|
|
|
|
+ const response = await this.client.find<SyncJobObject>(VOC_PARSE_CLASSES.syncJob, {
|
|
|
|
|
+ where: { status: 'pending', attempts: { $lt: 3 } },
|
|
|
|
|
+ order: 'requestedAt,createdAt',
|
|
|
|
|
+ limit: 1,
|
|
|
|
|
+ });
|
|
|
|
|
+ const job = response.results[0];
|
|
|
|
|
+ if (!job) return null;
|
|
|
|
|
+ const attempts = job.attempts + 1;
|
|
|
|
|
+ await this.client.update(VOC_PARSE_CLASSES.syncJob, job.objectId, {
|
|
|
|
|
+ status: 'processing',
|
|
|
|
|
+ workerId,
|
|
|
|
|
+ attempts,
|
|
|
|
|
+ startedAt: job.startedAt ?? parseDate(new Date()),
|
|
|
|
|
+ });
|
|
|
|
|
+ return {
|
|
|
|
|
+ internalId: job.objectId,
|
|
|
|
|
+ publicId: job.publicId,
|
|
|
|
|
+ workspaceId: job.workspaceId,
|
|
|
|
|
+ platform: job.platform,
|
|
|
|
|
+ scopes: job.scopes,
|
|
|
|
|
+ productIds: job.productIds,
|
|
|
|
|
+ attempts,
|
|
|
|
|
+ maxAttempts: job.maxAttempts,
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async upsertProduct(workspaceId: string, product: JdProductRecord): Promise<void> {
|
|
|
|
|
+ const naturalKey = key(workspaceId, product.platform, product.productId);
|
|
|
|
|
+ const existing = await this.client.findOne<ProductObject>(VOC_PARSE_CLASSES.product, { naturalKey });
|
|
|
|
|
+ const body = {
|
|
|
|
|
+ naturalKey,
|
|
|
|
|
+ workspaceId,
|
|
|
|
|
+ platform: product.platform,
|
|
|
|
|
+ productId: product.productId,
|
|
|
|
|
+ productKey: product.productKey,
|
|
|
|
|
+ role: product.role,
|
|
|
|
|
+ brand: product.brand || existing?.brand || '',
|
|
|
|
|
+ title: product.title || existing?.title || '',
|
|
|
|
|
+ model: product.model || existing?.model || '',
|
|
|
|
|
+ category1: product.category1 || existing?.category1 || '',
|
|
|
|
|
+ category2: product.category2 || existing?.category2 || '',
|
|
|
|
|
+ category3: product.category3 || existing?.category3 || '',
|
|
|
|
|
+ source: product.source,
|
|
|
|
|
+ relationCount: existing?.relationCount ?? 0,
|
|
|
|
|
+ summary: existing?.summary ?? {},
|
|
|
|
|
+ trend: existing?.trend ?? [],
|
|
|
|
|
+ rawPayload: product.rawPayload,
|
|
|
|
|
+ };
|
|
|
|
|
+ if (existing) await this.client.update(VOC_PARSE_CLASSES.product, existing.objectId, body);
|
|
|
|
|
+ else await this.client.create(VOC_PARSE_CLASSES.product, body);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async ensureProductStub(workspaceId: string, platform: string, productId: string): Promise<void> {
|
|
|
|
|
+ const workspace = await this.client.findOne(VOC_PARSE_CLASSES.workspace, { publicId: workspaceId, status: 'active' });
|
|
|
|
|
+ if (!workspace) throw new Error(`Workspace not found: ${workspaceId}`);
|
|
|
|
|
+ const naturalKey = key(workspaceId, platform, productId);
|
|
|
|
|
+ if (await this.client.findOne(VOC_PARSE_CLASSES.product, { naturalKey })) return;
|
|
|
|
|
+ await this.client.create(VOC_PARSE_CLASSES.product, {
|
|
|
|
|
+ naturalKey, workspaceId, platform, productId, productKey: `${platform}:${productId}`,
|
|
|
|
|
+ role: 'own', brand: '', title: '', model: '', category1: '', category2: '', category3: '',
|
|
|
|
|
+ source: 'sync_stub', relationCount: 0, summary: {}, trend: [],
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async upsertReviews(workspaceId: string, platform: string, productId: string, reviews: JdReviewRecord[]): Promise<number> {
|
|
|
|
|
+ for (const review of reviews) {
|
|
|
|
|
+ const naturalKey = key(workspaceId, platform, review.reviewKey);
|
|
|
|
|
+ const existing = await this.client.findOne<ReviewObject>(VOC_PARSE_CLASSES.review, { naturalKey });
|
|
|
|
|
+ const body = {
|
|
|
|
|
+ naturalKey, workspaceId, platform, productId,
|
|
|
|
|
+ sourceReviewId: review.reviewId || null,
|
|
|
|
|
+ reviewKey: review.reviewKey,
|
|
|
|
|
+ rating: review.rating,
|
|
|
|
|
+ content: review.content,
|
|
|
|
|
+ reviewDate: review.reviewDate ? parseDate(review.reviewDate) : null,
|
|
|
|
|
+ rawPayload: review.rawPayload,
|
|
|
|
|
+ };
|
|
|
|
|
+ if (existing) await this.client.update(VOC_PARSE_CLASSES.review, existing.objectId, body);
|
|
|
|
|
+ else await this.client.create(VOC_PARSE_CLASSES.review, body);
|
|
|
|
|
+ }
|
|
|
|
|
+ return reviews.length;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async setJobProgress(jobInternalId: string, progress: number): Promise<void> {
|
|
|
|
|
+ await this.client.update(VOC_PARSE_CLASSES.syncJob, jobInternalId, {
|
|
|
|
|
+ progress: Math.max(0, Math.min(100, Math.round(progress))),
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async finishJob(jobInternalId: string, status: SyncJobFinalStatus, errorSummary = ''): Promise<void> {
|
|
|
|
|
+ await this.client.update(VOC_PARSE_CLASSES.syncJob, jobInternalId, {
|
|
|
|
|
+ status,
|
|
|
|
|
+ progress: 100,
|
|
|
|
|
+ errorSummary: errorSummary ? errorSummary.slice(0, 1_000) : null,
|
|
|
|
|
+ completedAt: parseDate(new Date()),
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async requeueJob(jobInternalId: string, errorSummary: string): Promise<void> {
|
|
|
|
|
+ await this.client.update(VOC_PARSE_CLASSES.syncJob, jobInternalId, {
|
|
|
|
|
+ status: 'pending',
|
|
|
|
|
+ progress: 0,
|
|
|
|
|
+ workerId: null,
|
|
|
|
|
+ errorSummary: errorSummary.slice(0, 1_000),
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async addJobEvent(input: {
|
|
|
|
|
+ jobInternalId: string;
|
|
|
|
|
+ level: 'info' | 'warning' | 'error';
|
|
|
|
|
+ eventType: string;
|
|
|
|
|
+ message: string;
|
|
|
|
|
+ details?: Record<string, unknown>;
|
|
|
|
|
+ }): Promise<void> {
|
|
|
|
|
+ const job = await this.client.findOne<SyncJobObject>(VOC_PARSE_CLASSES.syncJob, { objectId: input.jobInternalId });
|
|
|
|
|
+ if (!job) return;
|
|
|
|
|
+ await this.createJobEvent(job.workspaceId, job.publicId, input.level, input.eventType, input.message, input.details ?? {});
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private async createJobEvent(
|
|
|
|
|
+ workspaceId: string,
|
|
|
|
|
+ jobPublicId: string,
|
|
|
|
|
+ level: string,
|
|
|
|
|
+ eventType: string,
|
|
|
|
|
+ message: string,
|
|
|
|
|
+ details: Record<string, unknown>,
|
|
|
|
|
+ ): Promise<void> {
|
|
|
|
|
+ await this.client.create(VOC_PARSE_CLASSES.syncJobEvent, {
|
|
|
|
|
+ publicId: randomUUID(), workspaceId, jobPublicId, level, eventType,
|
|
|
|
|
+ message: message.slice(0, 1_000), details,
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private mapMember(member: MemberObject & { objectId: string; createdAt: string; updatedAt: string }): WorkspaceMember {
|
|
|
|
|
+ return {
|
|
|
|
|
+ id: member.objectId,
|
|
|
|
|
+ workspaceId: member.workspaceId,
|
|
|
|
|
+ userId: member.userId,
|
|
|
|
|
+ email: member.email,
|
|
|
|
|
+ displayName: member.displayName,
|
|
|
|
|
+ role: member.role,
|
|
|
|
|
+ status: member.status,
|
|
|
|
|
+ createdAt: member.createdAt,
|
|
|
|
|
+ updatedAt: member.updatedAt,
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private mapProduct(product: ProductObject): DomesticProduct {
|
|
|
|
|
+ return {
|
|
|
|
|
+ platform: product.platform,
|
|
|
|
|
+ productId: product.productId,
|
|
|
|
|
+ productKey: product.productKey,
|
|
|
|
|
+ asin: product.productId,
|
|
|
|
|
+ role: product.role,
|
|
|
|
|
+ brand: product.brand,
|
|
|
|
|
+ title: product.title,
|
|
|
|
|
+ model: product.model,
|
|
|
|
|
+ category1: product.category1,
|
|
|
|
|
+ category2: product.category2,
|
|
|
|
|
+ category3: product.category3,
|
|
|
|
|
+ source: product.source,
|
|
|
|
|
+ relationCount: product.relationCount ?? 0,
|
|
|
|
|
+ summary: product.summary,
|
|
|
|
|
+ trend: product.trend ?? [],
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private mapJob(job: SyncJobObject): SyncJobRecord {
|
|
|
|
|
+ return {
|
|
|
|
|
+ id: job.publicId,
|
|
|
|
|
+ workspaceId: job.workspaceId,
|
|
|
|
|
+ platform: job.platform,
|
|
|
|
|
+ status: job.status,
|
|
|
|
|
+ scopes: job.scopes ?? [],
|
|
|
|
|
+ productIds: job.productIds ?? [],
|
|
|
|
|
+ progress: Number(job.progress ?? 0),
|
|
|
|
|
+ attempts: Number(job.attempts ?? 0),
|
|
|
|
|
+ maxAttempts: Number(job.maxAttempts ?? 3),
|
|
|
|
|
+ errorSummary: job.errorSummary || null,
|
|
|
|
|
+ requestedAt: requiredDate(job.requestedAt, job.createdAt),
|
|
|
|
|
+ startedAt: nullableDate(job.startedAt),
|
|
|
|
|
+ completedAt: nullableDate(job.completedAt),
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private mapAnalysis(item: AnalysisObject): AnalysisRun {
|
|
|
|
|
+ return {
|
|
|
|
|
+ id: item.publicId,
|
|
|
|
|
+ workspaceId: item.workspaceId,
|
|
|
|
|
+ analysisType: item.analysisType,
|
|
|
|
|
+ targetKind: item.targetKind,
|
|
|
|
|
+ targetKey: item.targetKey,
|
|
|
|
|
+ status: item.status,
|
|
|
|
|
+ input: item.input ?? {},
|
|
|
|
|
+ result: item.result ?? null,
|
|
|
|
|
+ evidenceCount: Number(item.evidenceCount ?? 0),
|
|
|
|
|
+ requestedBy: item.requestedBy,
|
|
|
|
|
+ errorSummary: item.errorSummary ?? null,
|
|
|
|
|
+ requestedAt: requiredDate(item.requestedAt, item.createdAt),
|
|
|
|
|
+ startedAt: nullableDate(item.startedAt),
|
|
|
|
|
+ completedAt: nullableDate(item.completedAt),
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private mapAction(item: ActionObject): ActionItem {
|
|
|
|
|
+ return {
|
|
|
|
|
+ id: item.publicId,
|
|
|
|
|
+ workspaceId: item.workspaceId,
|
|
|
|
|
+ actionType: item.actionType,
|
|
|
|
|
+ title: item.title,
|
|
|
|
|
+ description: item.description,
|
|
|
|
|
+ priority: item.priority,
|
|
|
|
|
+ status: item.status,
|
|
|
|
|
+ productKey: item.productKey ?? null,
|
|
|
|
|
+ assigneeUserId: item.assigneeUserId ?? null,
|
|
|
|
|
+ dueAt: nullableDate(item.dueAt),
|
|
|
|
|
+ createdBy: item.createdBy,
|
|
|
|
|
+ completedAt: nullableDate(item.completedAt),
|
|
|
|
|
+ createdAt: item.createdAt,
|
|
|
|
|
+ updatedAt: item.updatedAt ?? item.createdAt,
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private mapAlert(item: AlertObject): AlertItem {
|
|
|
|
|
+ return {
|
|
|
|
|
+ id: item.publicId,
|
|
|
|
|
+ workspaceId: item.workspaceId,
|
|
|
|
|
+ alertType: item.alertType,
|
|
|
|
|
+ severity: item.severity,
|
|
|
|
|
+ status: item.status,
|
|
|
|
|
+ productKey: item.productKey ?? null,
|
|
|
|
|
+ title: item.title,
|
|
|
|
|
+ summary: item.summary,
|
|
|
|
|
+ evidence: item.evidence ?? [],
|
|
|
|
|
+ detectedAt: requiredDate(item.detectedAt, item.createdAt),
|
|
|
|
|
+ acknowledgedBy: item.acknowledgedBy ?? null,
|
|
|
|
|
+ acknowledgedAt: nullableDate(item.acknowledgedAt),
|
|
|
|
|
+ resolvedAt: nullableDate(item.resolvedAt),
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+}
|