- RMM Overshell (migration 077): admin page, dispatch UI, executor/worker, target resolver, script registry (AD/DHCP/DNS/event-log/services/software/network/loglift) - LogLift evidence pipeline (migration 078): upload webhook, B2 storage client, receiver/matcher, EventLogCollector PowerShell script - IT Glue audit + write-back (migrations 075, 076): asset-audit runner, ticket xrefs, applications/configurations browse pages + apply/revert/audit endpoints - Link-aware analyzer bundles (migration 073) + provider toggle (migration 074): link-discovery service, OpenRouter LLM provider, related-tickets/itglue-suggestion panels, analyze-bundle endpoint - Endpoint data model + device-link reconciliation (migrations 079, 080): conflicts admin page, reconciler service, resolve endpoints - Dashboard overhaul: integration-health service + alerts, overview/health endpoints - Permissions: add itglue + rmm scopes; middleware: public /api/rmm/loglift route Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
612 lines
20 KiB
TypeScript
612 lines
20 KiB
TypeScript
/**
|
|
* Analyzer persistence: read/write to analyzer_analyses, analyzer_jobs, and
|
|
* analyzer_shares.
|
|
*
|
|
* All writes go through the postgresClient singleton — no transactions are
|
|
* needed for the row-per-analysis writes since each is independent and the
|
|
* unique (ticket_number, analysis_version) constraint prevents duplicates.
|
|
*/
|
|
|
|
import postgresClient from '@/lib/services/postgres-client';
|
|
import {
|
|
type AggregateFingerprint,
|
|
type AnalyzerJob,
|
|
type DeepAnalysisResponse,
|
|
type JobStatus,
|
|
type PersistedAnalysis,
|
|
type StageExecutionRecord,
|
|
type TaggedEvent,
|
|
} from '@/lib/types/analyzer';
|
|
import type { ITGlueDocReference } from '@/lib/types/analyzer';
|
|
|
|
export interface InsertAnalysisInput {
|
|
ticket_number: string;
|
|
autotask_ticket_id: number;
|
|
content_hash: string;
|
|
triggered_by_user_id: string | null;
|
|
status: 'complete' | 'failed';
|
|
/** When the analysis run finished (now() if undefined). */
|
|
completed_at?: Date;
|
|
|
|
/** anthropic | openrouter — defaults to 'anthropic' for back-compat. */
|
|
provider?: 'anthropic' | 'openrouter';
|
|
|
|
haiku_used: boolean;
|
|
sonnet_used: boolean;
|
|
opus_used: boolean;
|
|
total_input_tokens: number;
|
|
total_output_tokens: number;
|
|
estimated_cost_usd: number;
|
|
|
|
/** Final analysis content (after any Opus updates). null on failure. */
|
|
analysis: DeepAnalysisResponse | null;
|
|
filtered_noise_count: number;
|
|
/**
|
|
* LEGACY (phase 1). Per-stage trace dump. Retained for back-compat until
|
|
* analyzer_stage_executions has full coverage and we drop the column.
|
|
*/
|
|
model_traces: Record<string, unknown>;
|
|
/** Phase 2: Stage 0 preprocessed event list at analysis time. */
|
|
source_snapshot?: TaggedEvent[] | null;
|
|
error_message?: string | null;
|
|
}
|
|
|
|
/**
|
|
* Returns the next monotonic analysis_version for this ticket **and provider**.
|
|
* Uses MAX(...)+1 — there is a small race if two workers call this
|
|
* simultaneously, but the UNIQUE (ticket_number, provider, analysis_version)
|
|
* constraint catches it: the loser sees a 23505 unique_violation and the
|
|
* worker should retry with a fresh version number.
|
|
*/
|
|
export async function getNextAnalysisVersion(
|
|
ticketNumber: string,
|
|
provider: 'anthropic' | 'openrouter' = 'anthropic'
|
|
): Promise<number> {
|
|
const res = await postgresClient.query<{ next_version: string }>(
|
|
`SELECT COALESCE(MAX(analysis_version), 0) + 1 AS next_version
|
|
FROM analyzer_analyses
|
|
WHERE ticket_number = $1
|
|
AND provider = $2`,
|
|
[ticketNumber, provider]
|
|
);
|
|
return Number(res.rows[0].next_version);
|
|
}
|
|
|
|
/**
|
|
* Idempotency check: returns the most recent COMPLETE analysis row whose
|
|
* content_hash matches **for the given provider**, if any. Used to
|
|
* short-circuit re-runs when the source data hasn't changed and `force=false`.
|
|
*
|
|
* Provider-scoped so a Claude run doesn't short-circuit a request for a
|
|
* DeepSeek run (and vice versa) — the user wants a parallel analysis.
|
|
*/
|
|
export async function findExistingAnalysisByContentHash(
|
|
ticketNumber: string,
|
|
contentHash: string,
|
|
provider: 'anthropic' | 'openrouter' = 'anthropic'
|
|
): Promise<{ id: string; analysis_version: number } | null> {
|
|
const res = await postgresClient.query<{ id: string; analysis_version: string }>(
|
|
`SELECT id::text AS id, analysis_version::text AS analysis_version
|
|
FROM analyzer_analyses
|
|
WHERE ticket_number = $1
|
|
AND content_hash_at_analysis = $2
|
|
AND provider = $3
|
|
AND status = 'complete'
|
|
ORDER BY analysis_version DESC
|
|
LIMIT 1`,
|
|
[ticketNumber, contentHash, provider]
|
|
);
|
|
if (res.rowCount === 0) return null;
|
|
const row = res.rows[0];
|
|
return { id: row.id, analysis_version: Number(row.analysis_version) };
|
|
}
|
|
|
|
/**
|
|
* Insert a completed (or failed) analysis row. Returns the new row's id.
|
|
*
|
|
* Note: the unique (ticket_number, analysis_version) constraint catches racing
|
|
* writers. Caller should re-fetch the next version and retry if it sees a
|
|
* unique-violation error from postgres.
|
|
*/
|
|
export async function insertAnalysis(input: InsertAnalysisInput): Promise<{
|
|
id: string;
|
|
analysis_version: number;
|
|
}> {
|
|
const provider = input.provider ?? 'anthropic';
|
|
const version = await getNextAnalysisVersion(input.ticket_number, provider);
|
|
const completedAt = input.completed_at ?? new Date();
|
|
const a = input.analysis;
|
|
|
|
const res = await postgresClient.query<{ id: string }>(
|
|
`
|
|
INSERT INTO analyzer_analyses (
|
|
ticket_number, autotask_ticket_id, analysis_version,
|
|
content_hash_at_analysis, triggered_by_user_id,
|
|
status, completed_at,
|
|
haiku_used, sonnet_used, opus_used,
|
|
total_input_tokens, total_output_tokens, estimated_cost_usd,
|
|
summary, timeline, what_was_done, what_should_have_been_done,
|
|
gaps, next_step, next_step_rationale, post_resolution_analysis,
|
|
confidence_score, needs_human_review, human_review_reasons,
|
|
itglue_docs_referenced, model_traces, filtered_noise_count, error_message,
|
|
source_snapshot, provider
|
|
)
|
|
VALUES (
|
|
$1, $2, $3,
|
|
$4, $5,
|
|
$6, $7,
|
|
$8, $9, $10,
|
|
$11, $12, $13,
|
|
$14, $15::jsonb, $16::jsonb, $17::jsonb,
|
|
$18::jsonb, $19, $20, $21,
|
|
$22, $23, $24::jsonb,
|
|
$25::jsonb, $26::jsonb, $27, $28,
|
|
$29::jsonb, $30
|
|
)
|
|
RETURNING id::text AS id
|
|
`,
|
|
[
|
|
input.ticket_number,
|
|
input.autotask_ticket_id,
|
|
version,
|
|
input.content_hash,
|
|
input.triggered_by_user_id,
|
|
input.status,
|
|
completedAt,
|
|
input.haiku_used,
|
|
input.sonnet_used,
|
|
input.opus_used,
|
|
input.total_input_tokens,
|
|
input.total_output_tokens,
|
|
input.estimated_cost_usd,
|
|
a?.summary ?? null,
|
|
a?.timeline ? JSON.stringify(a.timeline) : null,
|
|
a?.what_was_done ? JSON.stringify(a.what_was_done) : null,
|
|
a?.what_should_have_been_done
|
|
? JSON.stringify(a.what_should_have_been_done)
|
|
: null,
|
|
a?.gaps ? JSON.stringify(a.gaps) : null,
|
|
a?.next_step ?? null,
|
|
a?.next_step_rationale ?? null,
|
|
a?.post_resolution_analysis ?? null,
|
|
a?.confidence_score ?? null,
|
|
a?.needs_human_review ?? false,
|
|
a?.human_review_reasons ? JSON.stringify(a.human_review_reasons) : null,
|
|
JSON.stringify(a?.itglue_docs_referenced ?? []),
|
|
JSON.stringify(input.model_traces),
|
|
input.filtered_noise_count,
|
|
input.error_message ?? null,
|
|
input.source_snapshot ? JSON.stringify(input.source_snapshot) : null,
|
|
provider,
|
|
]
|
|
);
|
|
|
|
return { id: res.rows[0].id, analysis_version: version };
|
|
}
|
|
|
|
/**
|
|
* Bulk-insert one analyzer_stage_executions row per record. No-op when records
|
|
* is empty. Single multi-VALUES INSERT — fast enough for the few rows produced
|
|
* per pipeline run that we don't need COPY.
|
|
*/
|
|
export async function bulkInsertStageExecutions(
|
|
analysisId: string,
|
|
records: StageExecutionRecord[]
|
|
): Promise<void> {
|
|
if (records.length === 0) return;
|
|
const values: unknown[] = [analysisId];
|
|
const tuples: string[] = [];
|
|
for (const r of records) {
|
|
const base = values.length;
|
|
values.push(
|
|
r.stage,
|
|
r.stage_order,
|
|
r.model_id,
|
|
JSON.stringify(r.input_payload ?? {}),
|
|
JSON.stringify(r.output_payload ?? {}),
|
|
r.input_tokens,
|
|
r.output_tokens,
|
|
r.latency_ms,
|
|
r.started_at,
|
|
r.completed_at,
|
|
r.error_message
|
|
);
|
|
tuples.push(
|
|
`($1, $${base + 1}, $${base + 2}, $${base + 3}, $${base + 4}::jsonb, ` +
|
|
`$${base + 5}::jsonb, $${base + 6}, $${base + 7}, $${base + 8}, ` +
|
|
`$${base + 9}, $${base + 10}, $${base + 11})`
|
|
);
|
|
}
|
|
await postgresClient.query(
|
|
`INSERT INTO analyzer_stage_executions (
|
|
analysis_id, stage, stage_order, model_id,
|
|
input_payload, output_payload,
|
|
input_tokens, output_tokens, latency_ms,
|
|
started_at, completed_at, error_message
|
|
) VALUES ${tuples.join(', ')}`,
|
|
values
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Phase 2: write the Stage 6 fingerprint to an existing analysis row.
|
|
*/
|
|
export async function updateAnalysisFingerprint(
|
|
analysisId: string,
|
|
fingerprint: AggregateFingerprint
|
|
): Promise<void> {
|
|
await postgresClient.query(
|
|
`UPDATE analyzer_analyses
|
|
SET aggregate_fingerprint = $2::jsonb,
|
|
fingerprint_generated_at = NOW()
|
|
WHERE id = $1`,
|
|
[analysisId, JSON.stringify(fingerprint)]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Phase 2: persist a 'failed' analyzer_analyses row when the pipeline throws.
|
|
* Carries content_hash + source_snapshot so partial-run forensics work, plus
|
|
* any stage records the pipeline managed to record before throwing.
|
|
*/
|
|
export async function insertFailedAnalysis(input: {
|
|
ticket_number: string;
|
|
autotask_ticket_id: number;
|
|
content_hash: string;
|
|
triggered_by_user_id: string | null;
|
|
source_snapshot: TaggedEvent[];
|
|
filtered_noise_count: number;
|
|
error_message: string;
|
|
partial_input_tokens: number;
|
|
partial_output_tokens: number;
|
|
partial_cost_usd: number;
|
|
haiku_used: boolean;
|
|
sonnet_used: boolean;
|
|
opus_used: boolean;
|
|
provider?: 'anthropic' | 'openrouter';
|
|
}): Promise<{ id: string; analysis_version: number }> {
|
|
return await insertAnalysis({
|
|
ticket_number: input.ticket_number,
|
|
autotask_ticket_id: input.autotask_ticket_id,
|
|
content_hash: input.content_hash,
|
|
triggered_by_user_id: input.triggered_by_user_id,
|
|
status: 'failed',
|
|
haiku_used: input.haiku_used,
|
|
sonnet_used: input.sonnet_used,
|
|
opus_used: input.opus_used,
|
|
total_input_tokens: input.partial_input_tokens,
|
|
total_output_tokens: input.partial_output_tokens,
|
|
estimated_cost_usd: input.partial_cost_usd,
|
|
analysis: null,
|
|
filtered_noise_count: input.filtered_noise_count,
|
|
model_traces: {},
|
|
source_snapshot: input.source_snapshot,
|
|
error_message: input.error_message,
|
|
provider: input.provider,
|
|
});
|
|
}
|
|
|
|
// =============================================================================
|
|
// Job table operations
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Try to claim the oldest queued job. Atomic via UPDATE ... WHERE ... RETURNING.
|
|
* Returns null if no queued jobs are available.
|
|
*/
|
|
export async function claimQueuedJob(): Promise<{
|
|
id: string;
|
|
ticket_number: string;
|
|
queued_by_user_id: string | null;
|
|
provider: 'anthropic' | 'openrouter';
|
|
} | null> {
|
|
const res = await postgresClient.query<{
|
|
id: string;
|
|
ticket_number: string;
|
|
queued_by_user_id: string | null;
|
|
provider: 'anthropic' | 'openrouter';
|
|
}>(
|
|
`
|
|
UPDATE analyzer_jobs
|
|
SET status = 'fetching', started_at = NOW()
|
|
WHERE id = (
|
|
SELECT id FROM analyzer_jobs
|
|
WHERE status = 'queued'
|
|
ORDER BY queued_at
|
|
FOR UPDATE SKIP LOCKED
|
|
LIMIT 1
|
|
)
|
|
RETURNING id::text AS id, ticket_number, queued_by_user_id, provider
|
|
`
|
|
);
|
|
if (res.rowCount === 0) return null;
|
|
return res.rows[0];
|
|
}
|
|
|
|
export async function updateJobStatus(
|
|
jobId: string,
|
|
status: JobStatus
|
|
): Promise<void> {
|
|
await postgresClient.query(
|
|
`UPDATE analyzer_jobs SET status = $1 WHERE id = $2`,
|
|
[status, jobId]
|
|
);
|
|
}
|
|
|
|
export async function completeJob(
|
|
jobId: string,
|
|
resultAnalysisId: string
|
|
): Promise<void> {
|
|
await postgresClient.query(
|
|
`UPDATE analyzer_jobs
|
|
SET status = 'complete',
|
|
result_analysis_id = $1,
|
|
finished_at = NOW()
|
|
WHERE id = $2`,
|
|
[resultAnalysisId, jobId]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Reclaim orphaned in-flight jobs whose started_at is older than the threshold.
|
|
* Called once on worker boot — any job in an active state (fetching/triaging/
|
|
* itglue/analyzing/deep_review) that's been "running" longer than the expected
|
|
* pipeline ceiling is almost certainly orphaned by a container restart and
|
|
* needs to be re-queued. Returns the number of rows reset.
|
|
*/
|
|
export async function resetStaleJobsToQueued(
|
|
thresholdMinutes = 10
|
|
): Promise<number> {
|
|
const res = await postgresClient.query(
|
|
`UPDATE analyzer_jobs
|
|
SET status = 'queued',
|
|
started_at = NULL
|
|
WHERE status IN ('fetching','triaging','itglue','analyzing','deep_review')
|
|
AND started_at IS NOT NULL
|
|
AND started_at < NOW() - ($1::int || ' minutes')::interval`,
|
|
[thresholdMinutes]
|
|
);
|
|
return res.rowCount ?? 0;
|
|
}
|
|
|
|
export async function failJob(jobId: string, errorMessage: string): Promise<void> {
|
|
await postgresClient.query(
|
|
`UPDATE analyzer_jobs
|
|
SET status = 'failed',
|
|
error_message = $1,
|
|
finished_at = NOW()
|
|
WHERE id = $2`,
|
|
[errorMessage, jobId]
|
|
);
|
|
}
|
|
|
|
export interface QueueJobInput {
|
|
ticket_number: string;
|
|
queued_by_user_id: string | null;
|
|
provider?: 'anthropic' | 'openrouter';
|
|
}
|
|
|
|
export async function queueJob(input: QueueJobInput): Promise<{ id: string }> {
|
|
const res = await postgresClient.query<{ id: string }>(
|
|
`INSERT INTO analyzer_jobs (ticket_number, queued_by_user_id, provider)
|
|
VALUES ($1, $2, $3)
|
|
RETURNING id::text AS id`,
|
|
[input.ticket_number, input.queued_by_user_id, input.provider ?? 'anthropic']
|
|
);
|
|
return { id: res.rows[0].id };
|
|
}
|
|
|
|
export async function getJob(jobId: string): Promise<AnalyzerJob | null> {
|
|
const res = await postgresClient.query<{
|
|
id: string;
|
|
ticket_number: string;
|
|
queued_by_user_id: string | null;
|
|
status: JobStatus;
|
|
result_analysis_id: string | null;
|
|
queued_at: Date;
|
|
started_at: Date | null;
|
|
finished_at: Date | null;
|
|
error_message: string | null;
|
|
}>(
|
|
`SELECT id::text AS id, ticket_number, queued_by_user_id, status,
|
|
result_analysis_id::text AS result_analysis_id,
|
|
queued_at, started_at, finished_at, error_message
|
|
FROM analyzer_jobs WHERE id = $1`,
|
|
[jobId]
|
|
);
|
|
if (res.rowCount === 0) return null;
|
|
const r = res.rows[0];
|
|
return {
|
|
id: r.id,
|
|
ticketNumber: r.ticket_number,
|
|
queuedByUserId: r.queued_by_user_id,
|
|
status: r.status,
|
|
resultAnalysisId: r.result_analysis_id,
|
|
queuedAt: r.queued_at.toISOString(),
|
|
startedAt: r.started_at ? r.started_at.toISOString() : null,
|
|
finishedAt: r.finished_at ? r.finished_at.toISOString() : null,
|
|
errorMessage: r.error_message,
|
|
};
|
|
}
|
|
|
|
// =============================================================================
|
|
// Read paths used by the API routes
|
|
// =============================================================================
|
|
|
|
interface AnalysisRow {
|
|
id: string;
|
|
ticket_number: string;
|
|
autotask_ticket_id: string;
|
|
analysis_version: string;
|
|
content_hash_at_analysis: string;
|
|
triggered_by_user_id: string | null;
|
|
triggered_at: Date;
|
|
status: PersistedAnalysis['status'];
|
|
completed_at: Date | null;
|
|
haiku_used: boolean;
|
|
sonnet_used: boolean;
|
|
opus_used: boolean;
|
|
total_input_tokens: number;
|
|
total_output_tokens: number;
|
|
estimated_cost_usd: string;
|
|
summary: string | null;
|
|
timeline: unknown;
|
|
what_was_done: unknown;
|
|
what_should_have_been_done: unknown;
|
|
gaps: unknown;
|
|
next_step: string | null;
|
|
next_step_rationale: string | null;
|
|
post_resolution_analysis: string | null;
|
|
confidence_score: string | null;
|
|
needs_human_review: boolean;
|
|
human_review_reasons: unknown;
|
|
itglue_docs_referenced: unknown;
|
|
filtered_noise_count: number;
|
|
error_message: string | null;
|
|
provider: 'anthropic' | 'openrouter';
|
|
}
|
|
|
|
function rowToPersistedAnalysis(r: AnalysisRow): PersistedAnalysis {
|
|
return {
|
|
id: r.id,
|
|
ticketNumber: r.ticket_number,
|
|
autotaskTicketId: Number(r.autotask_ticket_id),
|
|
analysisVersion: Number(r.analysis_version),
|
|
contentHashAtAnalysis: r.content_hash_at_analysis,
|
|
triggeredByUserId: r.triggered_by_user_id,
|
|
triggeredAt: r.triggered_at.toISOString(),
|
|
status: r.status,
|
|
completedAt: r.completed_at ? r.completed_at.toISOString() : null,
|
|
haikuUsed: r.haiku_used,
|
|
sonnetUsed: r.sonnet_used,
|
|
opusUsed: r.opus_used,
|
|
totalInputTokens: r.total_input_tokens,
|
|
totalOutputTokens: r.total_output_tokens,
|
|
estimatedCostUsd: Number(r.estimated_cost_usd),
|
|
summary: r.summary,
|
|
// JSONB columns deserialize directly to JS objects in node-postgres; cast
|
|
// to the schema type. We trust Zod-validated writes from the pipeline.
|
|
timeline: r.timeline as PersistedAnalysis['timeline'],
|
|
whatWasDone: r.what_was_done as PersistedAnalysis['whatWasDone'],
|
|
whatShouldHaveBeenDone:
|
|
r.what_should_have_been_done as PersistedAnalysis['whatShouldHaveBeenDone'],
|
|
gaps: r.gaps as PersistedAnalysis['gaps'],
|
|
nextStep: r.next_step,
|
|
nextStepRationale: r.next_step_rationale,
|
|
postResolutionAnalysis: r.post_resolution_analysis,
|
|
confidenceScore:
|
|
r.confidence_score === null ? null : Number(r.confidence_score),
|
|
needsHumanReview: r.needs_human_review,
|
|
humanReviewReasons: r.human_review_reasons as
|
|
| PersistedAnalysis['humanReviewReasons'],
|
|
itglueDocsReferenced:
|
|
(r.itglue_docs_referenced as PersistedAnalysis['itglueDocsReferenced']) ?? [],
|
|
filteredNoiseCount: r.filtered_noise_count,
|
|
errorMessage: r.error_message,
|
|
provider: r.provider,
|
|
};
|
|
}
|
|
|
|
const ANALYSIS_SELECT = `
|
|
id::text AS id,
|
|
ticket_number,
|
|
autotask_ticket_id::text AS autotask_ticket_id,
|
|
analysis_version::text AS analysis_version,
|
|
content_hash_at_analysis,
|
|
triggered_by_user_id,
|
|
triggered_at,
|
|
status,
|
|
completed_at,
|
|
haiku_used, sonnet_used, opus_used,
|
|
total_input_tokens, total_output_tokens, estimated_cost_usd::text AS estimated_cost_usd,
|
|
summary, timeline, what_was_done, what_should_have_been_done,
|
|
gaps, next_step, next_step_rationale, post_resolution_analysis,
|
|
confidence_score::text AS confidence_score,
|
|
needs_human_review,
|
|
human_review_reasons,
|
|
itglue_docs_referenced,
|
|
filtered_noise_count,
|
|
error_message,
|
|
provider
|
|
`;
|
|
|
|
export async function getAnalysisById(
|
|
id: string
|
|
): Promise<PersistedAnalysis | null> {
|
|
const res = await postgresClient.query<AnalysisRow>(
|
|
`SELECT ${ANALYSIS_SELECT} FROM analyzer_analyses WHERE id = $1`,
|
|
[id]
|
|
);
|
|
if (res.rowCount === 0) return null;
|
|
return rowToPersistedAnalysis(res.rows[0]);
|
|
}
|
|
|
|
export async function listAnalysesByTicketNumber(
|
|
ticketNumber: string
|
|
): Promise<PersistedAnalysis[]> {
|
|
// Order chronologically (most recent first) so the latest run shows up at
|
|
// the top of the history regardless of provider. Two providers maintain
|
|
// their own monotonic version numbers, so a strict version sort would
|
|
// interleave them oddly.
|
|
const res = await postgresClient.query<AnalysisRow>(
|
|
`SELECT ${ANALYSIS_SELECT}
|
|
FROM analyzer_analyses
|
|
WHERE ticket_number = $1
|
|
ORDER BY triggered_at DESC, analysis_version DESC`,
|
|
[ticketNumber]
|
|
);
|
|
return res.rows.map(rowToPersistedAnalysis);
|
|
}
|
|
|
|
export async function listNeedsReview(opts: {
|
|
limit?: number;
|
|
offset?: number;
|
|
} = {}): Promise<PersistedAnalysis[]> {
|
|
const limit = Math.min(opts.limit ?? 50, 200);
|
|
const offset = opts.offset ?? 0;
|
|
const res = await postgresClient.query<AnalysisRow>(
|
|
`SELECT ${ANALYSIS_SELECT}
|
|
FROM analyzer_analyses
|
|
WHERE needs_human_review = true
|
|
AND status = 'complete'
|
|
ORDER BY triggered_at DESC
|
|
LIMIT $1 OFFSET $2`,
|
|
[limit, offset]
|
|
);
|
|
return res.rows.map(rowToPersistedAnalysis);
|
|
}
|
|
|
|
// =============================================================================
|
|
// Share log
|
|
// =============================================================================
|
|
|
|
export interface CreateShareInput {
|
|
analysis_id: string;
|
|
shared_by_user_id: string;
|
|
shared_with_email: string;
|
|
note?: string | null;
|
|
}
|
|
|
|
export async function createShare(
|
|
input: CreateShareInput
|
|
): Promise<{ id: string; shared_at: string }> {
|
|
const res = await postgresClient.query<{ id: string; shared_at: Date }>(
|
|
`INSERT INTO analyzer_shares
|
|
(analysis_id, shared_by_user_id, shared_with_email, note)
|
|
VALUES ($1, $2, $3, $4)
|
|
RETURNING id::text AS id, shared_at`,
|
|
[
|
|
input.analysis_id,
|
|
input.shared_by_user_id,
|
|
input.shared_with_email,
|
|
input.note ?? null,
|
|
]
|
|
);
|
|
return {
|
|
id: res.rows[0].id,
|
|
shared_at: res.rows[0].shared_at.toISOString(),
|
|
};
|
|
}
|
|
|
|
// Re-export types referenced elsewhere.
|
|
export type { TaggedEvent, ITGlueDocReference, PersistedAnalysis };
|