|
|
@@ -0,0 +1,628 @@
|
|
|
+import type { Queryable } from '../../db/types.js';
|
|
|
+import { ApiError } from '../../http/api-error.js';
|
|
|
+import type { DomesticDataset, DomesticMetricSummary, DomesticProduct } from '../../types/domestic-dataset.js';
|
|
|
+import { SnapshotService } from '../domestic-voc/services/snapshot.service.js';
|
|
|
+import type {
|
|
|
+ ActionItem,
|
|
|
+ AlertItem,
|
|
|
+ AnalysisRun,
|
|
|
+ AuditEntry,
|
|
|
+ CursorPage,
|
|
|
+ DataSourceSummary,
|
|
|
+ DomesticProductDetail,
|
|
|
+ ImportBatchSummary,
|
|
|
+ PlatformRepository,
|
|
|
+ WorkspaceMember,
|
|
|
+ WorkspaceRole,
|
|
|
+ WorkspaceSummary,
|
|
|
+} from './domain.js';
|
|
|
+import { decodeCursor, encodeCursor } from './pagination.js';
|
|
|
+
|
|
|
+function numberValue(value: unknown): number {
|
|
|
+ const parsed = Number(value ?? 0);
|
|
|
+ return Number.isFinite(parsed) ? parsed : 0;
|
|
|
+}
|
|
|
+
|
|
|
+function iso(value: Date | string | null): string | null {
|
|
|
+ return value ? new Date(value).toISOString() : null;
|
|
|
+}
|
|
|
+
|
|
|
+function numericCursor(value: string | null, fallback: string): string {
|
|
|
+ if (!value) return fallback;
|
|
|
+ const decoded = decodeCursor(value);
|
|
|
+ if (!decoded || !/^\d+$/.test(decoded.id)) throw new ApiError(400, 'invalid_cursor');
|
|
|
+ try {
|
|
|
+ const id = BigInt(decoded.id);
|
|
|
+ if (id < 1n || id > 9_223_372_036_854_775_807n) throw new Error('out of range');
|
|
|
+ } catch {
|
|
|
+ throw new ApiError(400, 'invalid_cursor');
|
|
|
+ }
|
|
|
+ return decoded.id;
|
|
|
+}
|
|
|
+
|
|
|
+interface ProductRow {
|
|
|
+ cursor_id: string;
|
|
|
+ platform: string;
|
|
|
+ product_id: string;
|
|
|
+ product_key: string;
|
|
|
+ role: 'own' | 'competitor';
|
|
|
+ brand: string;
|
|
|
+ title: string;
|
|
|
+ model: string;
|
|
|
+ category_1: string;
|
|
|
+ category_2: string;
|
|
|
+ category_3: string;
|
|
|
+ source: string;
|
|
|
+ relation_count: string | number;
|
|
|
+ gmv: string | number;
|
|
|
+ sold_units: string | number;
|
|
|
+ transaction_orders: string | number;
|
|
|
+ transaction_customers: string | number;
|
|
|
+ impressions: string | number;
|
|
|
+ clicks: string | number;
|
|
|
+ views: string | number;
|
|
|
+ visitors: string | number;
|
|
|
+ cart_units: string | number;
|
|
|
+ order_amount: string | number;
|
|
|
+ order_units: string | number;
|
|
|
+ order_count: string | number;
|
|
|
+ refund_amount: string | number;
|
|
|
+ refund_units: string | number;
|
|
|
+ refund_orders: string | number;
|
|
|
+}
|
|
|
+
|
|
|
+function metricSummary(row: ProductRow): DomesticMetricSummary {
|
|
|
+ const summary = {
|
|
|
+ gmv: numberValue(row.gmv),
|
|
|
+ soldUnits: numberValue(row.sold_units),
|
|
|
+ transactionOrders: numberValue(row.transaction_orders),
|
|
|
+ transactionCustomers: numberValue(row.transaction_customers),
|
|
|
+ impressions: numberValue(row.impressions),
|
|
|
+ clicks: numberValue(row.clicks),
|
|
|
+ views: numberValue(row.views),
|
|
|
+ visitors: numberValue(row.visitors),
|
|
|
+ cartUnits: numberValue(row.cart_units),
|
|
|
+ orderAmount: numberValue(row.order_amount),
|
|
|
+ orderUnits: numberValue(row.order_units),
|
|
|
+ orderCount: numberValue(row.order_count),
|
|
|
+ refundAmount: numberValue(row.refund_amount),
|
|
|
+ refundUnits: numberValue(row.refund_units),
|
|
|
+ refundOrders: numberValue(row.refund_orders),
|
|
|
+ };
|
|
|
+ return {
|
|
|
+ ...summary,
|
|
|
+ conversionRate: summary.visitors ? summary.transactionOrders / summary.visitors : 0,
|
|
|
+ clickThroughRate: summary.impressions ? summary.clicks / summary.impressions : 0,
|
|
|
+ averageUnitPrice: summary.soldUnits ? summary.gmv / summary.soldUnits : 0,
|
|
|
+ refundToGmvRate: summary.gmv ? summary.refundAmount / summary.gmv : 0,
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function mapProduct(row: ProductRow): DomesticProduct {
|
|
|
+ return {
|
|
|
+ platform: row.platform,
|
|
|
+ productId: row.product_id,
|
|
|
+ productKey: row.product_key,
|
|
|
+ asin: row.product_id,
|
|
|
+ role: row.role,
|
|
|
+ brand: row.brand,
|
|
|
+ title: row.title,
|
|
|
+ model: row.model,
|
|
|
+ category1: row.category_1,
|
|
|
+ category2: row.category_2,
|
|
|
+ category3: row.category_3,
|
|
|
+ source: row.source,
|
|
|
+ relationCount: numberValue(row.relation_count),
|
|
|
+ summary: metricSummary(row),
|
|
|
+ trend: [],
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+const PRODUCT_SELECT = `
|
|
|
+ SELECT product.id::text AS cursor_id, product.platform, product.product_id, product.product_key,
|
|
|
+ product.role, product.brand, product.title, product.model, product.category_1,
|
|
|
+ product.category_2, product.category_3, product.source,
|
|
|
+ COALESCE(relation.relation_count, 0) AS relation_count,
|
|
|
+ COALESCE(metric.gmv, 0) AS gmv,
|
|
|
+ COALESCE(metric.sold_units, 0) AS sold_units,
|
|
|
+ COALESCE(metric.transaction_orders, 0) AS transaction_orders,
|
|
|
+ COALESCE(metric.transaction_customers, 0) AS transaction_customers,
|
|
|
+ COALESCE(metric.impressions, 0) AS impressions,
|
|
|
+ COALESCE(metric.clicks, 0) AS clicks,
|
|
|
+ COALESCE(metric.views, 0) AS views,
|
|
|
+ COALESCE(metric.visitors, 0) AS visitors,
|
|
|
+ COALESCE(metric.cart_units, 0) AS cart_units,
|
|
|
+ COALESCE(metric.order_amount, 0) AS order_amount,
|
|
|
+ COALESCE(metric.order_units, 0) AS order_units,
|
|
|
+ COALESCE(metric.order_count, 0) AS order_count,
|
|
|
+ COALESCE(metric.refund_amount, 0) AS refund_amount,
|
|
|
+ COALESCE(metric.refund_units, 0) AS refund_units,
|
|
|
+ COALESCE(metric.refund_orders, 0) AS refund_orders
|
|
|
+ FROM voc.product product
|
|
|
+ JOIN voc.workspace workspace ON workspace.id = product.workspace_id
|
|
|
+ LEFT JOIN LATERAL (
|
|
|
+ SELECT SUM(gmv) AS gmv, SUM(sold_units) AS sold_units,
|
|
|
+ SUM(transaction_orders) AS transaction_orders,
|
|
|
+ SUM(transaction_customers) AS transaction_customers,
|
|
|
+ SUM(impressions) AS impressions, SUM(clicks) AS clicks, SUM(views) AS views,
|
|
|
+ SUM(visitors) AS visitors, SUM(cart_units) AS cart_units,
|
|
|
+ SUM(order_amount) AS order_amount, SUM(order_units) AS order_units,
|
|
|
+ SUM(order_count) AS order_count, SUM(refund_amount) AS refund_amount,
|
|
|
+ SUM(refund_units) AS refund_units, SUM(refund_orders) AS refund_orders
|
|
|
+ FROM voc.daily_metric WHERE product_id = product.id
|
|
|
+ ) metric ON true
|
|
|
+ LEFT JOIN LATERAL (
|
|
|
+ SELECT COUNT(*) AS relation_count FROM voc.product_relation WHERE own_product_id = product.id
|
|
|
+ ) relation ON true
|
|
|
+`;
|
|
|
+
|
|
|
+export class PostgresPlatformRepository implements PlatformRepository {
|
|
|
+ private readonly snapshot: SnapshotService;
|
|
|
+
|
|
|
+ constructor(private readonly database: Queryable) {
|
|
|
+ this.snapshot = new SnapshotService(database);
|
|
|
+ }
|
|
|
+
|
|
|
+ async getDatasetSnapshot(workspaceId: string, platform: string): Promise<DomesticDataset | null> {
|
|
|
+ return this.snapshot.getSnapshot(workspaceId, platform);
|
|
|
+ }
|
|
|
+
|
|
|
+ async bootstrapAdmin(workspaceId: string, principal: { userId: string; email: string; displayName: string }): Promise<void> {
|
|
|
+ await this.upsertMember({ workspaceId, ...principal, role: 'owner', status: 'active' });
|
|
|
+ }
|
|
|
+
|
|
|
+ async listWorkspaces(userId: string): Promise<WorkspaceSummary[]> {
|
|
|
+ const result = await this.database.query<{
|
|
|
+ public_id: string; name: string; case_name: string; status: 'active' | 'disabled'; role: WorkspaceRole;
|
|
|
+ }>(`
|
|
|
+ SELECT workspace.public_id, workspace.name, workspace.case_name, workspace.status, member.role
|
|
|
+ FROM voc.workspace_member member
|
|
|
+ JOIN voc.workspace workspace ON workspace.id = member.workspace_id
|
|
|
+ WHERE member.user_external_id = $1 AND member.status = 'active'
|
|
|
+ ORDER BY workspace.id
|
|
|
+ `, [userId]);
|
|
|
+ return result.rows.map((row) => ({ id: row.public_id, name: row.name, caseName: row.case_name, status: row.status, role: row.role }));
|
|
|
+ }
|
|
|
+
|
|
|
+ async getMembership(workspaceId: string, userId: string): Promise<WorkspaceMember | null> {
|
|
|
+ const result = await this.database.query<{
|
|
|
+ id: string; public_id: string; user_external_id: string; email: string; display_name: string;
|
|
|
+ role: WorkspaceRole; status: WorkspaceMember['status']; created_at: Date | string; updated_at: Date | string;
|
|
|
+ }>(`
|
|
|
+ SELECT member.id::text, workspace.public_id, member.user_external_id, member.email,
|
|
|
+ member.display_name, member.role, member.status, member.created_at, member.updated_at
|
|
|
+ FROM voc.workspace_member member
|
|
|
+ JOIN voc.workspace workspace ON workspace.id = member.workspace_id
|
|
|
+ WHERE workspace.public_id = $1 AND workspace.status = 'active' AND member.user_external_id = $2
|
|
|
+ `, [workspaceId, userId]);
|
|
|
+ const row = result.rows[0];
|
|
|
+ return row ? this.mapMember(row) : null;
|
|
|
+ }
|
|
|
+
|
|
|
+ async listMembers(workspaceId: string): Promise<WorkspaceMember[]> {
|
|
|
+ const result = await this.database.query<{
|
|
|
+ id: string; public_id: string; user_external_id: string; email: string; display_name: string;
|
|
|
+ role: WorkspaceRole; status: WorkspaceMember['status']; created_at: Date | string; updated_at: Date | string;
|
|
|
+ }>(`
|
|
|
+ SELECT member.id::text, workspace.public_id, member.user_external_id, member.email,
|
|
|
+ member.display_name, member.role, member.status, member.created_at, member.updated_at
|
|
|
+ FROM voc.workspace_member member
|
|
|
+ JOIN voc.workspace workspace ON workspace.id = member.workspace_id
|
|
|
+ WHERE workspace.public_id = $1
|
|
|
+ ORDER BY member.id
|
|
|
+ `, [workspaceId]);
|
|
|
+ return result.rows.map((row) => this.mapMember(row));
|
|
|
+ }
|
|
|
+
|
|
|
+ async upsertMember(input: {
|
|
|
+ workspaceId: string; userId: string; email: string; displayName: string; role: WorkspaceRole; status: WorkspaceMember['status'];
|
|
|
+ }): Promise<WorkspaceMember | null> {
|
|
|
+ const result = await this.database.query<{
|
|
|
+ id: string; public_id: string; user_external_id: string; email: string; display_name: string;
|
|
|
+ role: WorkspaceRole; status: WorkspaceMember['status']; created_at: Date | string; updated_at: Date | string;
|
|
|
+ }>(`
|
|
|
+ WITH target AS (
|
|
|
+ SELECT id, public_id FROM voc.workspace WHERE public_id = $1 AND status = 'active'
|
|
|
+ ), saved AS (
|
|
|
+ INSERT INTO voc.workspace_member (workspace_id, user_external_id, email, display_name, role, status, invited_at)
|
|
|
+ SELECT id, $2, $3, $4, $5, $6, CASE WHEN $6 = 'invited' THEN now() ELSE NULL END FROM target
|
|
|
+ ON CONFLICT (workspace_id, user_external_id) DO UPDATE SET
|
|
|
+ email = EXCLUDED.email,
|
|
|
+ display_name = EXCLUDED.display_name,
|
|
|
+ role = EXCLUDED.role,
|
|
|
+ status = EXCLUDED.status,
|
|
|
+ updated_at = now()
|
|
|
+ RETURNING *
|
|
|
+ )
|
|
|
+ SELECT saved.id::text, target.public_id, saved.user_external_id, saved.email, saved.display_name,
|
|
|
+ saved.role, saved.status, saved.created_at, saved.updated_at
|
|
|
+ FROM saved JOIN target ON target.id = saved.workspace_id
|
|
|
+ `, [input.workspaceId, input.userId, input.email, input.displayName, input.role, input.status]);
|
|
|
+ const row = result.rows[0];
|
|
|
+ return row ? this.mapMember(row) : null;
|
|
|
+ }
|
|
|
+
|
|
|
+ async listProducts(input: {
|
|
|
+ workspaceId: string; platform: string; limit: number; cursor: string | null; search: string; role?: DomesticProduct['role']; category: string;
|
|
|
+ }): Promise<CursorPage<DomesticProduct>> {
|
|
|
+ const cursorId = numericCursor(input.cursor, '0');
|
|
|
+ const result = await this.database.query<ProductRow>(`${PRODUCT_SELECT}
|
|
|
+ WHERE workspace.public_id = $1 AND product.platform = $2 AND product.id > $3::bigint
|
|
|
+ AND product.source NOT IN ('relation_stub', 'sync_stub')
|
|
|
+ AND ($4::text = '' OR product.role = $4)
|
|
|
+ AND ($5::text = '' OR product.category_1 = $5 OR product.category_2 = $5 OR product.category_3 = $5)
|
|
|
+ AND ($6::text = '' OR product.product_id ILIKE '%' || $6 || '%' OR product.model ILIKE '%' || $6 || '%'
|
|
|
+ OR product.title ILIKE '%' || $6 || '%' OR product.brand ILIKE '%' || $6 || '%')
|
|
|
+ ORDER BY product.id
|
|
|
+ LIMIT $7
|
|
|
+ `, [input.workspaceId, input.platform, cursorId, input.role ?? '', input.category, input.search, input.limit + 1]);
|
|
|
+ const hasMore = result.rows.length > input.limit;
|
|
|
+ const selected = result.rows.slice(0, input.limit);
|
|
|
+ return {
|
|
|
+ items: selected.map(mapProduct),
|
|
|
+ nextCursor: hasMore && selected.length ? encodeCursor({ id: selected[selected.length - 1]!.cursor_id }) : null,
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ async getProduct(workspaceId: string, platform: string, productId: string): Promise<DomesticProductDetail | null> {
|
|
|
+ const result = await this.database.query<ProductRow>(`${PRODUCT_SELECT}
|
|
|
+ WHERE workspace.public_id = $1 AND product.platform = $2 AND product.product_id = $3
|
|
|
+ AND product.source NOT IN ('relation_stub', 'sync_stub')
|
|
|
+ `, [workspaceId, platform, productId]);
|
|
|
+ const row = result.rows[0];
|
|
|
+ if (!row) return null;
|
|
|
+ const [trend, reviews] = await Promise.all([
|
|
|
+ this.database.query<{ metric_date: Date | string; gmv: string | number; sold_units: string | number; transaction_orders: string | number }>(`
|
|
|
+ SELECT metric.metric_date, SUM(metric.gmv) AS gmv, SUM(metric.sold_units) AS sold_units,
|
|
|
+ SUM(metric.transaction_orders) AS transaction_orders
|
|
|
+ FROM voc.daily_metric metric
|
|
|
+ JOIN voc.product product ON product.id = metric.product_id
|
|
|
+ JOIN voc.workspace workspace ON workspace.id = metric.workspace_id
|
|
|
+ WHERE workspace.public_id = $1 AND product.platform = $2 AND product.product_id = $3
|
|
|
+ GROUP BY metric.metric_date ORDER BY metric.metric_date
|
|
|
+ `, [workspaceId, platform, productId]),
|
|
|
+ this.database.query<{ review_count: string | number; average_rating: string | number }>(`
|
|
|
+ SELECT COUNT(*) AS review_count, COALESCE(AVG(review.rating), 0) AS average_rating
|
|
|
+ FROM voc.review review
|
|
|
+ JOIN voc.product product ON product.id = review.product_id
|
|
|
+ JOIN voc.workspace workspace ON workspace.id = review.workspace_id
|
|
|
+ WHERE workspace.public_id = $1 AND product.platform = $2 AND product.product_id = $3
|
|
|
+ `, [workspaceId, platform, productId]),
|
|
|
+ ]);
|
|
|
+ const review = reviews.rows[0];
|
|
|
+ return {
|
|
|
+ ...mapProduct(row),
|
|
|
+ trend: trend.rows.map((item) => ({
|
|
|
+ date: new Date(item.metric_date).toISOString().slice(0, 10),
|
|
|
+ gmv: numberValue(item.gmv),
|
|
|
+ soldUnits: numberValue(item.sold_units),
|
|
|
+ transactionOrders: numberValue(item.transaction_orders),
|
|
|
+ })),
|
|
|
+ reviews: { count: numberValue(review?.review_count), averageRating: numberValue(review?.average_rating) },
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ async listReviews(input: { workspaceId: string; platform: string; productId: string; limit: number; cursor: string | null }) {
|
|
|
+ const cursorId = numericCursor(input.cursor, '0');
|
|
|
+ const result = await this.database.query<{
|
|
|
+ cursor_id: string; source_review_id: string | null; review_key: string; rating: string | number | null;
|
|
|
+ content: string; review_date: Date | string | null;
|
|
|
+ }>(`
|
|
|
+ SELECT review.id::text AS cursor_id, review.source_review_id, review.review_key, review.rating,
|
|
|
+ review.content, review.review_date
|
|
|
+ FROM voc.review review
|
|
|
+ JOIN voc.product product ON product.id = review.product_id
|
|
|
+ JOIN voc.workspace workspace ON workspace.id = review.workspace_id
|
|
|
+ WHERE workspace.public_id = $1 AND review.platform = $2 AND product.product_id = $3 AND review.id > $4::bigint
|
|
|
+ ORDER BY review.id LIMIT $5
|
|
|
+ `, [input.workspaceId, input.platform, input.productId, cursorId, input.limit + 1]);
|
|
|
+ const rows = result.rows;
|
|
|
+ const hasMore = rows.length > input.limit;
|
|
|
+ const selected = rows.slice(0, input.limit);
|
|
|
+ return {
|
|
|
+ items: selected.map((row) => ({
|
|
|
+ productId: input.productId,
|
|
|
+ reviewId: row.source_review_id || row.review_key,
|
|
|
+ rating: numberValue(row.rating),
|
|
|
+ content: row.content,
|
|
|
+ ...(row.review_date ? { reviewDate: new Date(row.review_date).toISOString() } : {}),
|
|
|
+ })),
|
|
|
+ nextCursor: hasMore && selected.length ? encodeCursor({ id: selected[selected.length - 1]!.cursor_id }) : null,
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ async listRelations(input: { workspaceId: string; platform: string; limit: number; cursor: string | null }) {
|
|
|
+ const cursorId = numericCursor(input.cursor, '0');
|
|
|
+ const result = await this.database.query<{
|
|
|
+ cursor_id: string; relation_key: string; own_product_key: string; own_product_id: string;
|
|
|
+ competitor_product_key: string; competitor_product_id: string; competitor_brand: string; category: string;
|
|
|
+ }>(`
|
|
|
+ SELECT relation.id::text AS cursor_id, relation.relation_key,
|
|
|
+ own.product_key AS own_product_key, own.product_id AS own_product_id,
|
|
|
+ competitor.product_key AS competitor_product_key, competitor.product_id AS competitor_product_id,
|
|
|
+ competitor.brand AS competitor_brand, relation.category
|
|
|
+ FROM voc.product_relation relation
|
|
|
+ JOIN voc.workspace workspace ON workspace.id = relation.workspace_id
|
|
|
+ JOIN voc.product own ON own.id = relation.own_product_id
|
|
|
+ JOIN voc.product competitor ON competitor.id = relation.competitor_product_id
|
|
|
+ WHERE workspace.public_id = $1 AND relation.platform = $2 AND relation.id > $3::bigint
|
|
|
+ ORDER BY relation.id LIMIT $4
|
|
|
+ `, [input.workspaceId, input.platform, cursorId, input.limit + 1]);
|
|
|
+ const hasMore = result.rows.length > input.limit;
|
|
|
+ const selected = result.rows.slice(0, input.limit);
|
|
|
+ return {
|
|
|
+ items: selected.map((row) => ({
|
|
|
+ relationKey: row.relation_key,
|
|
|
+ ownProductKey: row.own_product_key,
|
|
|
+ ownProductId: row.own_product_id,
|
|
|
+ competitorProductKey: row.competitor_product_key,
|
|
|
+ competitorProductId: row.competitor_product_id,
|
|
|
+ competitorBrand: row.competitor_brand,
|
|
|
+ category: row.category,
|
|
|
+ })),
|
|
|
+ nextCursor: hasMore && selected.length ? encodeCursor({ id: selected[selected.length - 1]!.cursor_id }) : null,
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ async listJobs(input: { workspaceId: string; limit: number; cursor: string | null; status: string }) {
|
|
|
+ const cursorId = numericCursor(input.cursor, '9223372036854775807');
|
|
|
+ const result = await this.database.query<{
|
|
|
+ cursor_id: string; public_id: string; workspace_public_id: string; platform: string; status: string;
|
|
|
+ scopes: string[]; product_ids: string[]; progress: number; attempts: number; max_attempts: number;
|
|
|
+ error_summary: string | null; requested_at: Date | string; started_at: Date | string | null; completed_at: Date | string | null;
|
|
|
+ }>(`
|
|
|
+ SELECT job.id::text AS cursor_id, job.public_id, workspace.public_id AS workspace_public_id,
|
|
|
+ job.platform, job.status, job.scopes, job.product_ids, job.progress, job.attempts,
|
|
|
+ job.max_attempts, job.error_summary, job.requested_at, job.started_at, job.completed_at
|
|
|
+ FROM voc.sync_job job JOIN voc.workspace workspace ON workspace.id = job.workspace_id
|
|
|
+ WHERE workspace.public_id = $1 AND job.id < $2::bigint AND ($3::text = '' OR job.status = $3)
|
|
|
+ ORDER BY job.id DESC LIMIT $4
|
|
|
+ `, [input.workspaceId, cursorId, input.status, input.limit + 1]);
|
|
|
+ const hasMore = result.rows.length > input.limit;
|
|
|
+ const selected = result.rows.slice(0, input.limit);
|
|
|
+ return {
|
|
|
+ items: selected.map((row) => ({
|
|
|
+ id: row.public_id, workspaceId: row.workspace_public_id, platform: row.platform, status: row.status,
|
|
|
+ scopes: row.scopes, productIds: row.product_ids, progress: row.progress, attempts: row.attempts,
|
|
|
+ maxAttempts: row.max_attempts, errorSummary: row.error_summary,
|
|
|
+ requestedAt: new Date(row.requested_at).toISOString(), startedAt: iso(row.started_at), completedAt: iso(row.completed_at),
|
|
|
+ })),
|
|
|
+ nextCursor: hasMore && selected.length ? encodeCursor({ id: selected[selected.length - 1]!.cursor_id }) : null,
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ async listJobEvents(workspaceId: string, jobId: string) {
|
|
|
+ const result = await this.database.query<{
|
|
|
+ id: string; level: string; event_type: string; message: string; details: Record<string, unknown>; created_at: Date | string;
|
|
|
+ }>(`
|
|
|
+ SELECT event.id::text, event.level, event.event_type, event.message, event.details, event.created_at
|
|
|
+ FROM voc.sync_job_event event
|
|
|
+ JOIN voc.sync_job job ON job.id = event.sync_job_id
|
|
|
+ JOIN voc.workspace workspace ON workspace.id = job.workspace_id
|
|
|
+ WHERE workspace.public_id = $1 AND job.public_id = $2
|
|
|
+ ORDER BY event.id
|
|
|
+ `, [workspaceId, jobId]);
|
|
|
+ return result.rows.map((row) => ({ id: row.id, level: row.level, type: row.event_type, message: row.message, details: row.details, createdAt: new Date(row.created_at).toISOString() }));
|
|
|
+ }
|
|
|
+
|
|
|
+ async listDataSources(workspaceId: string): Promise<DataSourceSummary[]> {
|
|
|
+ const result = await this.database.query<{
|
|
|
+ id: string; public_id: string; platform: string; connection_kind: string; status: string;
|
|
|
+ metadata: Record<string, unknown>; last_checked_at: Date | string | null;
|
|
|
+ }>(`
|
|
|
+ SELECT source.id::text, workspace.public_id, source.platform, source.connection_kind, source.status,
|
|
|
+ source.metadata, source.last_checked_at
|
|
|
+ FROM voc.source_connection source JOIN voc.workspace workspace ON workspace.id = source.workspace_id
|
|
|
+ WHERE workspace.public_id = $1 ORDER BY source.id
|
|
|
+ `, [workspaceId]);
|
|
|
+ return result.rows.map((row) => ({
|
|
|
+ id: row.id, workspaceId: row.public_id, platform: row.platform, kind: row.connection_kind,
|
|
|
+ status: row.status, lastCheckedAt: iso(row.last_checked_at),
|
|
|
+ credentialStorage: row.metadata.credentialStorage === 'external_secret' ? 'external_secret' : 'environment',
|
|
|
+ }));
|
|
|
+ }
|
|
|
+
|
|
|
+ async listImports(input: { workspaceId: string; limit: number; cursor: string | null }): Promise<CursorPage<ImportBatchSummary>> {
|
|
|
+ const cursorId = numericCursor(input.cursor, '9223372036854775807');
|
|
|
+ const result = await this.database.query<{
|
|
|
+ cursor_id: string; public_id: string; workspace_public_id: string; platform: string; source_kind: string;
|
|
|
+ source_file: string | null; status: string; total_rows: number; success_rows: number; failed_rows: number;
|
|
|
+ created_at: Date | string; completed_at: Date | string | null;
|
|
|
+ }>(`
|
|
|
+ SELECT batch.id::text AS cursor_id, batch.public_id, workspace.public_id AS workspace_public_id,
|
|
|
+ batch.platform, batch.source_kind, batch.source_file, batch.status, batch.total_rows,
|
|
|
+ batch.success_rows, batch.failed_rows, batch.created_at, batch.completed_at
|
|
|
+ FROM voc.import_batch batch JOIN voc.workspace workspace ON workspace.id = batch.workspace_id
|
|
|
+ WHERE workspace.public_id = $1 AND batch.id < $2::bigint
|
|
|
+ ORDER BY batch.id DESC LIMIT $3
|
|
|
+ `, [input.workspaceId, cursorId, input.limit + 1]);
|
|
|
+ const hasMore = result.rows.length > input.limit;
|
|
|
+ const selected = result.rows.slice(0, input.limit);
|
|
|
+ return {
|
|
|
+ items: selected.map((row) => ({
|
|
|
+ id: row.public_id, workspaceId: row.workspace_public_id, platform: row.platform,
|
|
|
+ sourceKind: row.source_kind, sourceFile: row.source_file, status: row.status,
|
|
|
+ totalRows: row.total_rows, successRows: row.success_rows, failedRows: row.failed_rows,
|
|
|
+ createdAt: new Date(row.created_at).toISOString(), completedAt: iso(row.completed_at),
|
|
|
+ })),
|
|
|
+ nextCursor: hasMore && selected.length ? encodeCursor({ id: selected[selected.length - 1]!.cursor_id }) : null,
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ async listAnalyses(input: { workspaceId: string; limit: number; cursor: string | null; status: string }) {
|
|
|
+ const cursorId = numericCursor(input.cursor, '9223372036854775807');
|
|
|
+ const result = await this.database.query<any>(`
|
|
|
+ SELECT run.id::text AS cursor_id, run.*, workspace.public_id AS workspace_public_id
|
|
|
+ FROM voc.analysis_run run JOIN voc.workspace workspace ON workspace.id = run.workspace_id
|
|
|
+ WHERE workspace.public_id = $1 AND run.id < $2::bigint AND ($3::text = '' OR run.status = $3)
|
|
|
+ ORDER BY run.id DESC LIMIT $4
|
|
|
+ `, [input.workspaceId, cursorId, input.status, input.limit + 1]);
|
|
|
+ return this.mappedPage(result.rows, input.limit, (row) => this.mapAnalysis(row));
|
|
|
+ }
|
|
|
+
|
|
|
+ async createAnalysis(input: Omit<AnalysisRun, 'status' | 'result' | 'evidenceCount' | 'errorSummary' | 'requestedAt' | 'startedAt' | 'completedAt'>): Promise<AnalysisRun> {
|
|
|
+ const result = await this.database.query<any>(`
|
|
|
+ INSERT INTO voc.analysis_run (public_id, workspace_id, analysis_type, target_kind, target_key, input, requested_by_external_id)
|
|
|
+ SELECT $2, workspace.id, $3, $4, $5, $6::jsonb, $7 FROM voc.workspace workspace WHERE workspace.public_id = $1
|
|
|
+ RETURNING *, $1::text AS workspace_public_id
|
|
|
+ `, [input.workspaceId, input.id, input.analysisType, input.targetKind, input.targetKey, JSON.stringify(input.input), input.requestedBy]);
|
|
|
+ const row = result.rows[0];
|
|
|
+ if (!row) throw new Error('Workspace not found while creating analysis');
|
|
|
+ return this.mapAnalysis(row);
|
|
|
+ }
|
|
|
+
|
|
|
+ async listActions(input: { workspaceId: string; limit: number; cursor: string | null; status: string }) {
|
|
|
+ const cursorId = numericCursor(input.cursor, '9223372036854775807');
|
|
|
+ const result = await this.database.query<any>(`
|
|
|
+ SELECT action.id::text AS cursor_id, action.*, workspace.public_id AS workspace_public_id
|
|
|
+ FROM voc.action_item action JOIN voc.workspace workspace ON workspace.id = action.workspace_id
|
|
|
+ WHERE workspace.public_id = $1 AND action.id < $2::bigint AND ($3::text = '' OR action.status = $3)
|
|
|
+ ORDER BY action.id DESC LIMIT $4
|
|
|
+ `, [input.workspaceId, cursorId, input.status, input.limit + 1]);
|
|
|
+ return this.mappedPage(result.rows, input.limit, (row) => this.mapAction(row));
|
|
|
+ }
|
|
|
+
|
|
|
+ async createAction(input: Omit<ActionItem, 'completedAt' | 'createdAt' | 'updatedAt'>): Promise<ActionItem> {
|
|
|
+ const result = await this.database.query<any>(`
|
|
|
+ INSERT INTO voc.action_item (
|
|
|
+ public_id, workspace_id, action_type, title, description, priority, status,
|
|
|
+ product_key, assignee_external_id, due_at, created_by_external_id, completed_at
|
|
|
+ )
|
|
|
+ SELECT $2, workspace.id, $3, $4, $5, $6, $7, $8, $9, $10, $11,
|
|
|
+ CASE WHEN $7 = 'completed' THEN now() ELSE NULL END
|
|
|
+ FROM voc.workspace workspace WHERE workspace.public_id = $1
|
|
|
+ RETURNING *, $1::text AS workspace_public_id
|
|
|
+ `, [input.workspaceId, input.id, input.actionType, input.title, input.description, input.priority,
|
|
|
+ input.status, input.productKey, input.assigneeUserId, input.dueAt, input.createdBy]);
|
|
|
+ const row = result.rows[0];
|
|
|
+ if (!row) throw new Error('Workspace not found while creating action');
|
|
|
+ return this.mapAction(row);
|
|
|
+ }
|
|
|
+
|
|
|
+ async updateAction(workspaceId: string, id: string, patch: Partial<Pick<ActionItem, 'title' | 'description' | 'priority' | 'status' | 'assigneeUserId' | 'dueAt'>>): Promise<ActionItem | null> {
|
|
|
+ const result = await this.database.query<any>(`
|
|
|
+ UPDATE voc.action_item action SET
|
|
|
+ title = COALESCE($3, action.title), description = COALESCE($4, action.description),
|
|
|
+ priority = COALESCE($5, action.priority), status = COALESCE($6, action.status),
|
|
|
+ assignee_external_id = CASE WHEN $7::boolean THEN $8 ELSE action.assignee_external_id END,
|
|
|
+ due_at = CASE WHEN $9::boolean THEN $10::timestamptz ELSE action.due_at END,
|
|
|
+ completed_at = CASE WHEN $6 = 'completed' THEN COALESCE(action.completed_at, now())
|
|
|
+ WHEN $6 IS NOT NULL THEN NULL ELSE action.completed_at END,
|
|
|
+ updated_at = now()
|
|
|
+ FROM voc.workspace workspace
|
|
|
+ WHERE action.workspace_id = workspace.id AND workspace.public_id = $1 AND action.public_id = $2
|
|
|
+ RETURNING action.*, workspace.public_id AS workspace_public_id, action.id::text AS cursor_id
|
|
|
+ `, [workspaceId, id, patch.title ?? null, patch.description ?? null, patch.priority ?? null, patch.status ?? null,
|
|
|
+ Object.hasOwn(patch, 'assigneeUserId'), patch.assigneeUserId ?? null,
|
|
|
+ Object.hasOwn(patch, 'dueAt'), patch.dueAt ?? null]);
|
|
|
+ return result.rows[0] ? this.mapAction(result.rows[0]) : null;
|
|
|
+ }
|
|
|
+
|
|
|
+ async listAlerts(input: { workspaceId: string; limit: number; cursor: string | null; status: string }) {
|
|
|
+ const cursorId = numericCursor(input.cursor, '9223372036854775807');
|
|
|
+ const result = await this.database.query<any>(`
|
|
|
+ SELECT alert.id::text AS cursor_id, alert.*, workspace.public_id AS workspace_public_id
|
|
|
+ FROM voc.alert alert JOIN voc.workspace workspace ON workspace.id = alert.workspace_id
|
|
|
+ WHERE workspace.public_id = $1 AND alert.id < $2::bigint AND ($3::text = '' OR alert.status = $3)
|
|
|
+ ORDER BY alert.id DESC LIMIT $4
|
|
|
+ `, [input.workspaceId, cursorId, input.status, input.limit + 1]);
|
|
|
+ return this.mappedPage(result.rows, input.limit, (row) => this.mapAlert(row));
|
|
|
+ }
|
|
|
+
|
|
|
+ async createAlert(input: Omit<AlertItem, 'detectedAt' | 'acknowledgedBy' | 'acknowledgedAt' | 'resolvedAt'>): Promise<AlertItem> {
|
|
|
+ const result = await this.database.query<any>(`
|
|
|
+ INSERT INTO voc.alert (public_id, workspace_id, alert_type, severity, status, product_key, title, summary, evidence)
|
|
|
+ SELECT $2, workspace.id, $3, $4, $5, $6, $7, $8, $9::jsonb
|
|
|
+ FROM voc.workspace workspace WHERE workspace.public_id = $1
|
|
|
+ RETURNING *, $1::text AS workspace_public_id
|
|
|
+ `, [input.workspaceId, input.id, input.alertType, input.severity, input.status, input.productKey,
|
|
|
+ input.title, input.summary, JSON.stringify(input.evidence)]);
|
|
|
+ const row = result.rows[0];
|
|
|
+ if (!row) throw new Error('Workspace not found while creating alert');
|
|
|
+ return this.mapAlert(row);
|
|
|
+ }
|
|
|
+
|
|
|
+ async updateAlert(workspaceId: string, id: string, patch: { status: AlertItem['status']; actorUserId: string }): Promise<AlertItem | null> {
|
|
|
+ const result = await this.database.query<any>(`
|
|
|
+ UPDATE voc.alert alert SET status = $3,
|
|
|
+ acknowledged_by_external_id = CASE WHEN $3 = 'acknowledged' THEN $4 ELSE alert.acknowledged_by_external_id END,
|
|
|
+ acknowledged_at = CASE WHEN $3 = 'acknowledged' THEN COALESCE(alert.acknowledged_at, now()) ELSE alert.acknowledged_at END,
|
|
|
+ resolved_at = CASE WHEN $3 = 'resolved' THEN COALESCE(alert.resolved_at, now())
|
|
|
+ WHEN $3 IN ('open', 'acknowledged') THEN NULL ELSE alert.resolved_at END,
|
|
|
+ updated_at = now()
|
|
|
+ FROM voc.workspace workspace
|
|
|
+ WHERE alert.workspace_id = workspace.id AND workspace.public_id = $1 AND alert.public_id = $2
|
|
|
+ RETURNING alert.*, workspace.public_id AS workspace_public_id, alert.id::text AS cursor_id
|
|
|
+ `, [workspaceId, id, patch.status, patch.actorUserId]);
|
|
|
+ return result.rows[0] ? this.mapAlert(result.rows[0]) : null;
|
|
|
+ }
|
|
|
+
|
|
|
+ async listAudit(input: { workspaceId: string; limit: number; cursor: string | null }) {
|
|
|
+ const cursorId = numericCursor(input.cursor, '9223372036854775807');
|
|
|
+ const result = await this.database.query<any>(`
|
|
|
+ SELECT audit.id::text AS cursor_id, audit.*, workspace.public_id AS workspace_public_id
|
|
|
+ FROM voc.audit_log audit JOIN voc.workspace workspace ON workspace.id = audit.workspace_id
|
|
|
+ WHERE workspace.public_id = $1 AND audit.id < $2::bigint
|
|
|
+ ORDER BY audit.id DESC LIMIT $3
|
|
|
+ `, [input.workspaceId, cursorId, input.limit + 1]);
|
|
|
+ return this.mappedPage(result.rows, input.limit, (row) => ({
|
|
|
+ id: row.cursor_id, workspaceId: row.workspace_public_id, actorUserId: row.actor_external_id,
|
|
|
+ action: row.action, entityType: row.entity_type, entityId: row.entity_public_id,
|
|
|
+ metadata: row.metadata ?? {}, createdAt: new Date(row.created_at).toISOString(),
|
|
|
+ } as AuditEntry));
|
|
|
+ }
|
|
|
+
|
|
|
+ async appendAudit(input: Omit<AuditEntry, 'id' | 'createdAt'>): Promise<void> {
|
|
|
+ await this.database.query(`
|
|
|
+ INSERT INTO voc.audit_log (workspace_id, actor_external_id, action, entity_type, entity_public_id, metadata)
|
|
|
+ SELECT workspace.id, $2, $3, $4, $5, $6::jsonb FROM voc.workspace workspace WHERE workspace.public_id = $1
|
|
|
+ `, [input.workspaceId, input.actorUserId, input.action, input.entityType, input.entityId, JSON.stringify(input.metadata)]);
|
|
|
+ }
|
|
|
+
|
|
|
+ private mapMember(row: any): WorkspaceMember {
|
|
|
+ return {
|
|
|
+ id: row.id, workspaceId: row.public_id, userId: row.user_external_id, email: row.email,
|
|
|
+ displayName: row.display_name, role: row.role, status: row.status,
|
|
|
+ createdAt: new Date(row.created_at).toISOString(), updatedAt: new Date(row.updated_at).toISOString(),
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ private mapAnalysis(row: any): AnalysisRun {
|
|
|
+ return {
|
|
|
+ id: row.public_id, workspaceId: row.workspace_public_id, analysisType: row.analysis_type,
|
|
|
+ targetKind: row.target_kind, targetKey: row.target_key, status: row.status, input: row.input ?? {},
|
|
|
+ result: row.result ?? null, evidenceCount: row.evidence_count, requestedBy: row.requested_by_external_id,
|
|
|
+ errorSummary: row.error_summary, requestedAt: new Date(row.requested_at).toISOString(),
|
|
|
+ startedAt: iso(row.started_at), completedAt: iso(row.completed_at),
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ private mapAction(row: any): ActionItem {
|
|
|
+ return {
|
|
|
+ id: row.public_id, workspaceId: row.workspace_public_id, actionType: row.action_type,
|
|
|
+ title: row.title, description: row.description, priority: row.priority, status: row.status,
|
|
|
+ productKey: row.product_key, assigneeUserId: row.assignee_external_id, dueAt: iso(row.due_at),
|
|
|
+ createdBy: row.created_by_external_id, completedAt: iso(row.completed_at),
|
|
|
+ createdAt: new Date(row.created_at).toISOString(), updatedAt: new Date(row.updated_at).toISOString(),
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ private mapAlert(row: any): AlertItem {
|
|
|
+ return {
|
|
|
+ id: row.public_id, workspaceId: row.workspace_public_id, alertType: row.alert_type,
|
|
|
+ severity: row.severity, status: row.status, productKey: row.product_key,
|
|
|
+ title: row.title, summary: row.summary, evidence: Array.isArray(row.evidence) ? row.evidence : [],
|
|
|
+ detectedAt: new Date(row.detected_at).toISOString(), acknowledgedBy: row.acknowledged_by_external_id,
|
|
|
+ acknowledgedAt: iso(row.acknowledged_at), resolvedAt: iso(row.resolved_at),
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ private mappedPage<T>(rows: any[], limit: number, map: (row: any) => T): CursorPage<T> {
|
|
|
+ const hasMore = rows.length > limit;
|
|
|
+ const selected = rows.slice(0, limit);
|
|
|
+ return {
|
|
|
+ items: selected.map(map),
|
|
|
+ nextCursor: hasMore && selected.length ? encodeCursor({ id: selected[selected.length - 1].cursor_id }) : null,
|
|
|
+ };
|
|
|
+ }
|
|
|
+}
|