parse-rest.schema.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449
  1. import type { ParseClassSchema, ParseFieldDefinition } from './parse-rest.client.js';
  2. import { ParseRestClient } from './parse-rest.client.js';
  3. const string = (required = false): ParseFieldDefinition => ({ type: 'String', required });
  4. const number = (required = false): ParseFieldDefinition => ({ type: 'Number', required });
  5. const boolean = (required = false): ParseFieldDefinition => ({ type: 'Boolean', required });
  6. const date = (required = false): ParseFieldDefinition => ({ type: 'Date', required });
  7. const object = (required = false): ParseFieldDefinition => ({ type: 'Object', required });
  8. const array = (required = false): ParseFieldDefinition => ({ type: 'Array', required });
  9. const indexes = (prefix: string, ...fields: string[]): Record<string, Record<string, 1>> => Object.fromEntries(
  10. fields.map((field) => [`${prefix}_${field.toLowerCase()}_idx`, { [field]: 1 }]),
  11. );
  12. export const VOC_PARSE_CLASSES = {
  13. workspace: 'VocWorkspace',
  14. sourceConnection: 'VocSourceConnection',
  15. importBatch: 'VocImportBatch',
  16. product: 'VocProduct',
  17. dailyMetric: 'VocDailyMetric',
  18. productRelation: 'VocProductRelation',
  19. review: 'VocReview',
  20. syncJob: 'VocSyncJob',
  21. syncJobEvent: 'VocSyncJobEvent',
  22. workspaceMember: 'VocWorkspaceMember',
  23. analysisRun: 'VocAnalysisRun',
  24. insightDecision: 'VocInsightDecision',
  25. actionItem: 'VocActionItem',
  26. alert: 'VocAlert',
  27. auditLog: 'VocAuditLog',
  28. promptConfig: 'VocPromptConfig',
  29. productKnowledge: 'VocProductKnowledge',
  30. listingSourceSnapshot: 'VocListingSourceSnapshot',
  31. listingScoreJob: 'VocListingScoreJob',
  32. listingScoreItem: 'VocListingScoreItem',
  33. listingCurrentScore: 'VocListingCurrentScore',
  34. listingVersion: 'VocListingVersion',
  35. competitorListingSnapshot: 'VocCompetitorListingSnapshot',
  36. competitorListingChange: 'VocCompetitorListingChange',
  37. competitorListingRefreshRun: 'VocCompetitorListingRefreshRun',
  38. competitorOptimizationTask: 'VocCompetitorOptimizationTask',
  39. } as const;
  40. export const JD_VOC_SCORE_SLOTS = ['jd_voc_rules', 'jd_voc_hybrid_ai'] as const;
  41. export const VOC_PARSE_SCHEMAS: ParseClassSchema[] = [
  42. {
  43. className: VOC_PARSE_CLASSES.workspace,
  44. fields: {
  45. publicId: string(true), name: string(true), caseName: string(true), status: string(true),
  46. },
  47. indexes: indexes('voc_workspace', 'publicId', 'status'),
  48. },
  49. {
  50. className: VOC_PARSE_CLASSES.sourceConnection,
  51. fields: {
  52. naturalKey: string(true), workspaceId: string(true), platform: string(true),
  53. connectionKind: string(true), status: string(true), metadata: object(true), lastCheckedAt: date(),
  54. },
  55. indexes: indexes('voc_source', 'naturalKey', 'workspaceId', 'platform', 'status'),
  56. },
  57. {
  58. className: VOC_PARSE_CLASSES.importBatch,
  59. fields: {
  60. publicId: string(true), workspaceId: string(true), platform: string(true), sourceKind: string(true),
  61. sourceFile: string(), sourceHash: string(), sheets: array(true), status: string(true),
  62. totalRows: number(true), successRows: number(true), failedRows: number(true),
  63. productRows: number(true), metricRows: number(true), relationRows: number(true), reviewRows: number(true),
  64. startedAt: date(), completedAt: date(), sourceDateStart: string(), sourceDateEnd: string(),
  65. dailyTotals: array(true), quality: object(true),
  66. },
  67. indexes: indexes('voc_import', 'publicId', 'workspaceId', 'platform', 'sourceHash', 'status', 'createdAt'),
  68. },
  69. {
  70. className: VOC_PARSE_CLASSES.product,
  71. fields: {
  72. naturalKey: string(true), workspaceId: string(true), platform: string(true), productId: string(true),
  73. productKey: string(true), role: string(true), brand: string(), title: string(), model: string(),
  74. category1: string(), category2: string(), category3: string(), source: string(true),
  75. relationCount: number(true), summary: object(true), trend: array(true), rawPayload: object(),
  76. },
  77. indexes: indexes('voc_product', 'naturalKey', 'workspaceId', 'platform', 'productId', 'productKey', 'role', 'category2', 'category3', 'source'),
  78. },
  79. {
  80. className: VOC_PARSE_CLASSES.dailyMetric,
  81. fields: {
  82. naturalKey: string(true), workspaceId: string(true), platform: string(true), productId: string(true),
  83. metricDate: string(true), source: string(true), gmv: number(true), soldUnits: number(true),
  84. transactionOrders: number(true), transactionCustomers: number(true), impressions: number(true),
  85. clicks: number(true), views: number(true), visitors: number(true), cartUnits: number(true),
  86. orderAmount: number(true), orderUnits: number(true), orderCount: number(true),
  87. refundAmount: number(true), refundUnits: number(true), refundOrders: number(true),
  88. },
  89. indexes: indexes('voc_metric', 'naturalKey', 'workspaceId', 'platform', 'productId', 'metricDate'),
  90. },
  91. {
  92. className: VOC_PARSE_CLASSES.productRelation,
  93. fields: {
  94. naturalKey: string(true), workspaceId: string(true), platform: string(true), relationKey: string(true),
  95. ownProductId: string(true), ownProductKey: string(true), competitorProductId: string(true),
  96. competitorProductKey: string(true), competitorBrand: string(), category: string(),
  97. ownModel: string(), ownCategory1: string(), ownCategory2: string(), ownCategory3: string(),
  98. },
  99. indexes: indexes('voc_relation', 'naturalKey', 'workspaceId', 'platform', 'ownProductId', 'competitorProductId'),
  100. },
  101. {
  102. className: VOC_PARSE_CLASSES.review,
  103. fields: {
  104. naturalKey: string(true), workspaceId: string(true), platform: string(true), productId: string(true),
  105. sourceReviewId: string(), reviewKey: string(true), rating: number(), content: string(true),
  106. reviewDate: date(), rawPayload: object(),
  107. },
  108. indexes: indexes('voc_review', 'naturalKey', 'workspaceId', 'platform', 'productId', 'reviewDate'),
  109. },
  110. {
  111. className: VOC_PARSE_CLASSES.syncJob,
  112. fields: {
  113. publicId: string(true), workspaceId: string(true), platform: string(true), idempotencyKey: string(true),
  114. status: string(true), scopes: array(true), productIds: array(true), progress: number(true),
  115. attempts: number(true), maxAttempts: number(true), workerId: string(), errorSummary: string(),
  116. requestedAt: date(true), startedAt: date(), completedAt: date(),
  117. },
  118. indexes: indexes('voc_job', 'publicId', 'workspaceId', 'platform', 'idempotencyKey', 'status', 'requestedAt'),
  119. },
  120. {
  121. className: VOC_PARSE_CLASSES.syncJobEvent,
  122. fields: {
  123. publicId: string(true), workspaceId: string(true), jobPublicId: string(true), level: string(true),
  124. eventType: string(true), message: string(true), details: object(true),
  125. },
  126. indexes: indexes('voc_job_event', 'publicId', 'workspaceId', 'jobPublicId', 'createdAt'),
  127. },
  128. {
  129. className: VOC_PARSE_CLASSES.workspaceMember,
  130. fields: {
  131. naturalKey: string(true), workspaceId: string(true), userId: string(true), email: string(),
  132. displayName: string(), role: string(true), status: string(true), productIds: array(),
  133. },
  134. indexes: indexes('voc_member', 'naturalKey', 'workspaceId', 'userId', 'role', 'status'),
  135. },
  136. {
  137. className: VOC_PARSE_CLASSES.analysisRun,
  138. fields: {
  139. publicId: string(true), workspaceId: string(true), analysisType: string(true), targetKind: string(true),
  140. targetKey: string(true), status: string(true), input: object(true), result: object(),
  141. evidenceCount: number(true), requestedBy: string(true), errorSummary: string(),
  142. requestedAt: date(true), startedAt: date(), completedAt: date(),
  143. },
  144. indexes: indexes('voc_analysis', 'publicId', 'workspaceId', 'status', 'targetKey', 'requestedAt'),
  145. },
  146. {
  147. className: VOC_PARSE_CLASSES.insightDecision,
  148. fields: {
  149. publicId: string(true), workspaceId: string(true), sourceAnalysisId: string(true),
  150. sourceInsightId: string(true), decision: string(true), reviewedEvidenceIds: array(true),
  151. comment: string(true), decidedBy: string(true), decidedAt: date(true), version: number(true),
  152. supersedesId: string(), isCurrent: boolean(true),
  153. },
  154. indexes: {
  155. ...indexes(
  156. 'voc_insight_decision',
  157. 'publicId',
  158. 'workspaceId',
  159. 'sourceAnalysisId',
  160. 'sourceInsightId',
  161. 'decision',
  162. 'decidedBy',
  163. 'decidedAt',
  164. 'isCurrent',
  165. ),
  166. voc_insight_decision_source_current_idx: {
  167. workspaceId: 1,
  168. sourceAnalysisId: 1,
  169. sourceInsightId: 1,
  170. isCurrent: 1,
  171. },
  172. voc_insight_decision_source_version_idx: {
  173. workspaceId: 1,
  174. sourceAnalysisId: 1,
  175. sourceInsightId: 1,
  176. version: -1,
  177. },
  178. },
  179. },
  180. {
  181. className: VOC_PARSE_CLASSES.actionItem,
  182. fields: {
  183. publicId: string(true), workspaceId: string(true), sourceAnalysisId: string(), sourceInsightId: string(),
  184. sourceDecisionId: string(), sourceKind: string(true), creationKey: string(true),
  185. evidenceIds: array(), validationMetric: string(), actionType: string(true),
  186. title: string(true), description: string(), priority: string(true), status: string(true),
  187. productKey: string(), assigneeUserId: string(), dueAt: date(), createdBy: string(true), completedAt: date(),
  188. },
  189. indexes: {
  190. ...indexes(
  191. 'voc_action',
  192. 'publicId',
  193. 'workspaceId',
  194. 'sourceAnalysisId',
  195. 'sourceDecisionId',
  196. 'sourceKind',
  197. 'creationKey',
  198. 'status',
  199. 'assigneeUserId',
  200. 'createdAt',
  201. ),
  202. voc_action_workspace_creation_key_idx: { workspaceId: 1, creationKey: 1 },
  203. },
  204. },
  205. {
  206. className: VOC_PARSE_CLASSES.alert,
  207. fields: {
  208. publicId: string(true), workspaceId: string(true), alertType: string(true), severity: string(true),
  209. status: string(true), productKey: string(), title: string(true), summary: string(), evidence: array(true),
  210. detectedAt: date(true), acknowledgedBy: string(), acknowledgedAt: date(), resolvedAt: date(),
  211. },
  212. indexes: indexes('voc_alert', 'publicId', 'workspaceId', 'status', 'productKey', 'detectedAt'),
  213. },
  214. {
  215. className: VOC_PARSE_CLASSES.auditLog,
  216. fields: {
  217. publicId: string(true), workspaceId: string(true), actorUserId: string(true), action: string(true),
  218. entityType: string(true), entityId: string(), metadata: object(true),
  219. },
  220. indexes: indexes('voc_audit', 'publicId', 'workspaceId', 'entityType', 'createdAt'),
  221. },
  222. {
  223. className: VOC_PARSE_CLASSES.promptConfig,
  224. fields: {
  225. naturalKey: string(true), workspaceId: string(true), promptKey: string(true),
  226. name: string(true), module: string(true), scope: string(true), description: string(),
  227. template: string(true), enabled: boolean(true), variables: array(true), dataSources: array(true),
  228. targetKey: string(), targetStylesJson: string(), model: string(), revision: number(true),
  229. updatedBy: string(true),
  230. },
  231. indexes: indexes('voc_prompt', 'naturalKey', 'workspaceId', 'promptKey', 'module', 'scope'),
  232. },
  233. {
  234. className: VOC_PARSE_CLASSES.productKnowledge,
  235. fields: {
  236. naturalKey: string(true), workspaceId: string(true), productKey: string(true), productId: string(true),
  237. productRole: string(true), featured: boolean(true), status: string(true), tags: array(true),
  238. note: string(), ownerUserId: string(), createdBy: string(true), updatedBy: string(true),
  239. },
  240. indexes: indexes(
  241. 'voc_product_knowledge',
  242. 'naturalKey',
  243. 'workspaceId',
  244. 'productKey',
  245. 'productId',
  246. 'productRole',
  247. 'featured',
  248. 'status',
  249. 'updatedAt',
  250. ),
  251. },
  252. {
  253. className: VOC_PARSE_CLASSES.listingSourceSnapshot,
  254. fields: {
  255. naturalKey: string(true), workspaceId: string(true), platform: string(true), shopId: string(true),
  256. productId: string(true), sourceHash: string(true), detailStatus: string(true), payload: object(true),
  257. title: string(), categoryIds: array(), itemStatus: string(), coverageStatus: string(), isCurrent: boolean(),
  258. catalogIncluded: boolean(), catalogCohort: string(),
  259. scoreStatus: string(), aiScoreStatus: string(), latestOverallScore: number(), latestKnownScore: number(), latestKnownMaxScore: number(), latestComplianceStatus: string(), latestScoreAt: date(),
  260. sourceModifiedAt: date(), observedAt: date(true),
  261. },
  262. indexes: indexes('voc_listing_source', 'naturalKey', 'workspaceId', 'platform', 'productId', 'sourceHash', 'isCurrent', 'catalogIncluded', 'catalogCohort', 'coverageStatus', 'scoreStatus', 'aiScoreStatus', 'latestOverallScore', 'observedAt'),
  263. },
  264. {
  265. className: VOC_PARSE_CLASSES.listingScoreJob,
  266. fields: {
  267. publicId: string(true), naturalKey: string(true), workspaceId: string(true), platform: string(true),
  268. idempotencyKey: string(true), requestHash: string(true), status: string(true), payload: object(true), requestedAt: date(true),
  269. },
  270. indexes: indexes('voc_listing_job', 'publicId', 'naturalKey', 'workspaceId', 'status', 'requestedAt'),
  271. },
  272. {
  273. className: VOC_PARSE_CLASSES.listingScoreItem,
  274. fields: {
  275. publicId: string(true), naturalKey: string(true), workspaceId: string(true), jobId: string(true),
  276. productId: string(true), sourceHash: string(true), status: string(true), payload: object(true),
  277. },
  278. indexes: indexes('voc_listing_item', 'publicId', 'naturalKey', 'workspaceId', 'jobId', 'status', 'productId'),
  279. },
  280. {
  281. className: VOC_PARSE_CLASSES.listingCurrentScore,
  282. fields: {
  283. publicId: string(true), naturalKey: string(true), workspaceId: string(true), productId: string(true),
  284. slot: string(true), sourceHash: string(true), rubricVersion: string(true), overallScore: number(), knownOverallScore: number(), knownOverallMaxScore: number(),
  285. scoreKind: string(), aiStatus: string(), complianceStatus: string(), executionKey: string(), inputFingerprint: string(), payload: object(true), scoredAt: date(true),
  286. },
  287. indexes: {
  288. ...indexes('voc_listing_current_score', 'publicId', 'naturalKey', 'workspaceId', 'productId', 'slot', 'sourceHash', 'executionKey', 'scoredAt'),
  289. voc_listing_current_score_rubric_version_idx: { workspaceId: 1, productId: 1, rubricVersion: 1 },
  290. voc_listing_current_score_kind_idx: { workspaceId: 1, productId: 1, scoreKind: 1 },
  291. },
  292. },
  293. {
  294. className: VOC_PARSE_CLASSES.listingVersion,
  295. fields: {
  296. publicId: string(true), naturalKey: string(true), workspaceId: string(true), productId: string(true),
  297. versionNo: number(true), baseSourceHash: string(true), status: string(true), payload: object(true), versionCreatedAt: date(true),
  298. },
  299. indexes: indexes('voc_listing_version', 'publicId', 'naturalKey', 'workspaceId', 'productId', 'status', 'versionCreatedAt'),
  300. },
  301. {
  302. className: VOC_PARSE_CLASSES.competitorListingSnapshot,
  303. fields: {
  304. publicId: string(true), naturalKey: string(true), workspaceId: string(true), platform: string(true),
  305. productId: string(true), previousSnapshotId: string(), contentHash: string(true), observedAt: date(true),
  306. title: string(), priceCents: number(), currency: string(true), availability: string(true), mainImageUrl: string(),
  307. keySpecifications: object(true), observedFields: array(true), collectionStatus: string(true),
  308. },
  309. indexes: indexes(
  310. 'voc_competitor_listing_snapshot',
  311. 'publicId',
  312. 'naturalKey',
  313. 'workspaceId',
  314. 'platform',
  315. 'productId',
  316. 'previousSnapshotId',
  317. 'contentHash',
  318. 'observedAt',
  319. ),
  320. },
  321. {
  322. className: VOC_PARSE_CLASSES.competitorListingChange,
  323. fields: {
  324. publicId: string(true), naturalKey: string(true), workspaceId: string(true), platform: string(true),
  325. productId: string(true), previousSnapshotId: string(true), currentSnapshotId: string(true),
  326. detectedAt: date(true), changeTypes: array(true), changes: array(true),
  327. },
  328. indexes: indexes(
  329. 'voc_competitor_listing_change',
  330. 'publicId',
  331. 'naturalKey',
  332. 'workspaceId',
  333. 'platform',
  334. 'productId',
  335. 'previousSnapshotId',
  336. 'currentSnapshotId',
  337. 'detectedAt',
  338. ),
  339. },
  340. {
  341. className: VOC_PARSE_CLASSES.competitorListingRefreshRun,
  342. fields: {
  343. publicId: string(true), workspaceId: string(true), platform: string(true), trigger: string(true),
  344. status: string(true), total: number(true), completed: number(true), baseline: number(true),
  345. unchanged: number(true), changed: number(true), failed: number(true), itemResults: array(true),
  346. requestedAt: date(true), startedAt: date(), completedAt: date(),
  347. },
  348. indexes: indexes(
  349. 'voc_competitor_listing_refresh_run',
  350. 'publicId',
  351. 'workspaceId',
  352. 'platform',
  353. 'status',
  354. 'requestedAt',
  355. ),
  356. },
  357. {
  358. className: VOC_PARSE_CLASSES.competitorOptimizationTask,
  359. fields: {
  360. publicId: string(true), workspaceId: string(true), competitorSnapshotId: string(true), ownProductId: string(true),
  361. dimension: string(true), creationKey: string(true), status: string(true), impactScore: number(), beforeScore: number(), afterScore: number(), delta: number(), effectiveness: string(),
  362. },
  363. indexes: indexes('voc_competitor_optimization_task', 'publicId', 'workspaceId', 'creationKey', 'status', 'competitorSnapshotId', 'ownProductId'),
  364. },
  365. ];
  366. export interface ParseSchemaSyncResult {
  367. created: string[];
  368. updated: string[];
  369. unchanged: string[];
  370. }
  371. export async function ensureVocParseSchemas(client: ParseRestClient): Promise<ParseSchemaSyncResult> {
  372. return ensureParseSchemas(client, VOC_PARSE_SCHEMAS);
  373. }
  374. export const LISTING_PARSE_SCHEMAS = VOC_PARSE_SCHEMAS.filter((schema) => (
  375. schema.className === VOC_PARSE_CLASSES.listingSourceSnapshot
  376. || schema.className === VOC_PARSE_CLASSES.listingScoreJob
  377. || schema.className === VOC_PARSE_CLASSES.listingScoreItem
  378. || schema.className === VOC_PARSE_CLASSES.listingCurrentScore
  379. || schema.className === VOC_PARSE_CLASSES.listingVersion
  380. ));
  381. export async function ensureListingParseSchemas(client: ParseRestClient): Promise<ParseSchemaSyncResult> {
  382. return ensureParseSchemas(client, LISTING_PARSE_SCHEMAS);
  383. }
  384. async function ensureParseSchemas(client: ParseRestClient, schemas: ParseClassSchema[]): Promise<ParseSchemaSyncResult> {
  385. const existing = new Map((await client.schemas()).map((schema) => [schema.className, schema]));
  386. const result: ParseSchemaSyncResult = { created: [], updated: [], unchanged: [] };
  387. for (const schema of schemas) {
  388. const current = existing.get(schema.className);
  389. if (!current) {
  390. await client.createSchema(schema);
  391. result.created.push(schema.className);
  392. continue;
  393. }
  394. const changed = Object.fromEntries(
  395. Object.entries(schema.fields).filter(([name, definition]) => {
  396. const field = current.fields?.[name];
  397. return !field
  398. || field.type !== definition.type
  399. || (field.required !== undefined && Boolean(field.required) !== Boolean(definition.required));
  400. }),
  401. );
  402. const fieldsChanged = Object.keys(changed).length > 0;
  403. const missingIndexes = Object.fromEntries(
  404. Object.entries(schema.indexes ?? {}).filter(([name]) => !current.indexes?.[name]),
  405. );
  406. const indexesChanged = Object.keys(missingIndexes).length > 0;
  407. const permissionsChanged = !hasMasterOnlyPermissions(current.classLevelPermissions);
  408. if (fieldsChanged) {
  409. await client.addSchemaFields(schema.className, changed);
  410. }
  411. if (permissionsChanged) {
  412. await client.setSchemaPermissions(schema.className);
  413. }
  414. if (indexesChanged) {
  415. await client.addSchemaIndexes(schema.className, missingIndexes);
  416. }
  417. if (fieldsChanged || permissionsChanged || indexesChanged) {
  418. result.updated.push(schema.className);
  419. } else {
  420. result.unchanged.push(schema.className);
  421. }
  422. }
  423. return result;
  424. }
  425. export function hasMasterOnlyPermissions(
  426. permissions: Record<string, Record<string, unknown>> | undefined,
  427. ): boolean {
  428. if (!permissions) return false;
  429. return ['find', 'get', 'count', 'create', 'update', 'delete', 'addField'].every((operation) => (
  430. Object.hasOwn(permissions, operation) && Object.keys(permissions[operation] ?? {}).length === 0
  431. ));
  432. }
  433. export function isVocSchemaReady(classNames: Iterable<string>): boolean {
  434. const available = new Set(classNames);
  435. return VOC_PARSE_SCHEMAS.every((schema) => available.has(schema.className));
  436. }
  437. export const requiredVocClassNames = VOC_PARSE_SCHEMAS.map((schema) => schema.className);