diff --git a/src/app/api/__tests__/cases.test.ts b/src/app/api/__tests__/cases.test.ts new file mode 100644 index 0000000..0b9f13c --- /dev/null +++ b/src/app/api/__tests__/cases.test.ts @@ -0,0 +1,375 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const llmMocks = vi.hoisted(() => ({ + generateValidatedJson: vi.fn(), +})); + +vi.mock("../../../lib/llm/client", async () => { + const actual = await vi.importActual( + "../../../lib/llm/client", + ); + return { + ...actual, + generateValidatedJson: llmMocks.generateValidatedJson, + }; +}); + +import { createSqliteRepository } from "../../../lib/db/sqlite-repository"; +import { GET as listCases } from "../cases/route"; +import { GET as getCase, PATCH as patchCase } from "../cases/[caseId]/route"; +import { POST as archiveCase } from "../cases/[caseId]/archive/route"; +import { POST as restoreCase } from "../cases/[caseId]/restore/route"; +import { POST as rerunCase } from "../cases/[caseId]/rerun/route"; +import { + GET as listVersionPublications, + POST as createVersionPublication, +} from "../cases/[caseId]/versions/[versionId]/publications/route"; +import { POST as recordPerformance } from "../publications/[publicationId]/performance/route"; + +describe("case APIs", () => { + let tempDir: string; + const originalDataDir = process.env.APP_DATA_DIR; + const originalApiKey = process.env.API_ACCESS_KEY; + const originalAuthDisabled = process.env.API_AUTH_DISABLED; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "geo-cases-api-")); + process.env.APP_DATA_DIR = tempDir; + process.env.API_ACCESS_KEY = "test-key"; + process.env.API_AUTH_DISABLED = "false"; + }); + + afterEach(() => { + process.env.APP_DATA_DIR = originalDataDir; + process.env.API_ACCESS_KEY = originalApiKey; + process.env.API_AUTH_DISABLED = originalAuthDisabled; + llmMocks.generateValidatedJson.mockReset(); + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("lists, reads, patches, archives, and restores cases", async () => { + const repository = createSqliteRepository(); + const created = await repository.createOptimizationCase({ + case_type: "human_copy", + title: "人味文案优化:朋友圈", + summary: "原文", + publish_target: "朋友圈", + source_excerpt: "原文", + }); + + const patchResponse = await patchCase( + request({ + customer_name: "客户A", + brand_name: "品牌B", + project_tags: ["朋友圈"], + notes: "保留口语。", + }), + params({ caseId: created.id }), + ); + expect(patchResponse.status).toBe(200); + + const archiveResponse = await archiveCase( + request({}), + params({ caseId: created.id }), + ); + expect(archiveResponse.status).toBe(200); + + const listResponse = await listCases(request({})); + const listBody = (await listResponse.json()) as { cases: unknown[] }; + expect(listBody.cases).toHaveLength(0); + + const restoreResponse = await restoreCase( + request({}), + params({ caseId: created.id }), + ); + expect(restoreResponse.status).toBe(200); + + const detailResponse = await getCase( + request({}), + params({ caseId: created.id }), + ); + const detailBody = (await detailResponse.json()) as { + case: { customer_name: string; project_tags: string[] }; + }; + expect(detailBody.case.customer_name).toBe("客户A"); + expect(detailBody.case.project_tags).toEqual(["朋友圈"]); + }); + + it("creates publication records for result versions", async () => { + const repository = createSqliteRepository(); + const created = await repository.createOptimizationCase({ + case_type: "human_copy", + title: "人味文案优化:私域", + summary: "原文", + publish_target: "私域", + source_excerpt: "原文", + }); + const version = await repository.createOptimizationResultVersion({ + case_id: created.id, + case_type: "human_copy", + status: "optimized", + article_job_id: null, + article_revision: null, + result_summary: "优化后文案", + payload: { + optimized_text: "优化后文案", + change_notes: [], + ai_taste_checks: [], + warnings: [], + }, + process_summary: [], + llm_audit_summary: [], + error_stage: null, + error_summary: null, + }); + + const createResponse = await createVersionPublication( + request({ + publish_target: "私域", + url: "https://example.com/private", + published_at: "2026-07-08T12:00:00.000Z", + notes: "客户发布", + }), + params({ caseId: created.id, versionId: version.id }), + ); + expect(createResponse.status).toBe(201); + + const listResponse = await listVersionPublications( + request({}), + params({ caseId: created.id, versionId: version.id }), + ); + const listBody = (await listResponse.json()) as { + publications: Array<{ publish_target: string }>; + }; + expect(listBody.publications).toEqual([ + expect.objectContaining({ publish_target: "私域" }), + ]); + }); + + it("records manual performance for a result-version publication without scoring", async () => { + const repository = createSqliteRepository(); + const created = await repository.createOptimizationCase({ + case_type: "human_copy", + title: "人味文案优化:私域", + summary: "原文", + publish_target: "私域", + source_excerpt: "原文", + }); + const version = await repository.createOptimizationResultVersion({ + case_id: created.id, + case_type: "human_copy", + status: "optimized", + article_job_id: null, + article_revision: null, + result_summary: "优化后文案", + payload: { + optimized_text: "优化后文案", + change_notes: [], + ai_taste_checks: [], + warnings: [], + }, + process_summary: [], + llm_audit_summary: [], + error_stage: null, + error_summary: null, + }); + const publication = await repository.createPublicationRecord({ + result_version_id: version.id, + job_id: null, + revision: null, + publish_target: "私域", + url: "https://example.com/private", + published_at: "2026-07-08T12:00:00.000Z", + status: "published", + notes: "", + }); + + const response = await recordPerformance( + request({ + window_label: "T+7d", + views: "1200", + feedback_summary: "客户反馈更自然", + }), + params({ publicationId: publication.id }), + ); + const body = (await response.json()) as { + snapshot?: { metrics: { views: number } }; + calibrationEvent: null; + }; + + expect(response.status).toBe(201); + expect(body.snapshot?.metrics.views).toBe(1200); + expect(body.calibrationEvent).toBeNull(); + }); + + it("reruns an article case into a new result version and article job", async () => { + const repository = createSqliteRepository(); + const created = await repository.createOptimizationCase({ + case_type: "article", + title: "GEO 指南", + summary: "原文摘要", + publish_target: "official_site", + source_excerpt: "原文摘要", + }); + const sourceJob = await repository.createArticleJob({ + case_id: created.id, + source_title: "GEO 指南", + source_body: "Example Technology Co., Ltd. has 8 years of GEO experience.", + image_inputs: [{ type: "description", content: "dashboard" }], + publish_platform: "official_site", + user_instructions: "保持事实准确", + }); + await repository.saveFactCard(sourceJob.id, confirmedFactCard); + await repository.saveCaseInput({ + case_id: created.id, + case_type: "article", + article_job_id: sourceJob.id, + payload: { + source_title: "GEO 指南", + source_body: "Example Technology Co., Ltd. has 8 years of GEO experience.", + image_inputs: [{ type: "description", content: "dashboard" }], + publish_platform: "official_site", + user_instructions: "保持事实准确", + }, + }); + await repository.createOptimizationResultVersion({ + case_id: created.id, + case_type: "article", + status: "optimized", + article_job_id: null, + article_revision: null, + result_summary: "第一版", + payload: null, + process_summary: [], + llm_audit_summary: [], + error_stage: null, + error_summary: null, + }); + + llmMocks.generateValidatedJson + .mockResolvedValueOnce({ + title: "第二版 GEO 指南", + summary: "第二版 official site GEO optimization 摘要", + body_markdown: + "## 第二版\nExample Technology Co., Ltd. has 8 years of GEO optimization experience.", + image_suggestions: [], + changed_sections: ["标题"], + requires_user_confirmation: [], + }) + .mockResolvedValueOnce({ + overall_status: "pass", + checks: [], + }); + + const response = await rerunCase(request({}), params({ caseId: created.id })); + const body = (await response.json()) as { + result_version: { version: number }; + optimizedArticle: { title: string; job_id: string }; + qaReport: { overall_status: string }; + exportPaths: Record; + }; + + expect(response.status).toBe(201); + expect(body.result_version.version).toBe(2); + expect(body.optimizedArticle.title).toBe("第二版 GEO 指南"); + expect(body.qaReport.overall_status).toBe("pass"); + expect(body.exportPaths.markdown).toContain(body.optimizedArticle.job_id); + await expect(repository.getArticleJob(body.optimizedArticle.job_id)).resolves.toMatchObject({ + case_id: created.id, + status: "optimized", + }); + }); + + it("reruns a human-copy case into a new result version", async () => { + const repository = createSqliteRepository(); + const created = await repository.createOptimizationCase({ + case_type: "human_copy", + title: "人味文案优化:朋友圈", + summary: "原文", + publish_target: "朋友圈", + source_excerpt: "原文", + }); + await repository.saveCaseInput({ + case_id: created.id, + case_type: "human_copy", + article_job_id: null, + payload: { + source_text: "原文", + goal: "自然一点", + intensity: "light", + user_instructions: "", + publish_target: "朋友圈", + }, + }); + await repository.createOptimizationResultVersion({ + case_id: created.id, + case_type: "human_copy", + status: "optimized", + article_job_id: null, + article_revision: null, + result_summary: "第一版", + payload: { + optimized_text: "第一版", + change_notes: [], + ai_taste_checks: [], + warnings: [], + }, + process_summary: [], + llm_audit_summary: [], + error_stage: null, + error_summary: null, + }); + + llmMocks.generateValidatedJson.mockResolvedValueOnce({ + optimized_text: "第二版", + change_notes: [], + ai_taste_checks: [], + warnings: [], + }); + + const response = await rerunCase(request({}), params({ caseId: created.id })); + const body = (await response.json()) as { + result_version: { version: number }; + result: { optimized_text: string }; + }; + + expect(response.status).toBe(201); + expect(body.result_version.version).toBe(2); + expect(body.result.optimized_text).toBe("第二版"); + }); +}); + +const confirmedFactCard = { + company_full_name: "Example Technology Co., Ltd.", + company_short_names: ["Example Tech"], + brand_names: ["Example"], + product_names: ["Example GEO"], + target_industry: "GEO optimization", + target_audience: "Marketing teams", + experience_years: 8, + core_claims: ["8 years of GEO optimization experience"], + forbidden_claims: ["industry first"], + image_topics: ["dashboard"], + uncertain_items: [], + is_ready_for_optimization: true, + confirmed_by_user: true, +} as const; + +function request(body: unknown, options: { apiKey?: string | null } = {}) { + const headers: Record = { "content-type": "application/json" }; + const apiKey = options.apiKey === undefined ? "test-key" : options.apiKey; + if (apiKey) headers["x-api-key"] = apiKey; + return new Request("http://localhost/api/cases", { + method: "POST", + body: JSON.stringify(body), + headers, + }); +} + +function params>(values: T) { + return { params: Promise.resolve(values) }; +} diff --git a/src/app/api/cases/[caseId]/archive/route.ts b/src/app/api/cases/[caseId]/archive/route.ts new file mode 100644 index 0000000..64165b0 --- /dev/null +++ b/src/app/api/cases/[caseId]/archive/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from "next/server"; + +import { requireApiAccess } from "../../../../../lib/api/auth"; +import { getRepositoryFromRuntime } from "../../../../../lib/db/repository"; + +interface RouteContext { + params: Promise<{ caseId: string }>; +} + +export async function POST(request: Request, context: RouteContext) { + const access = requireApiAccess(request); + if (!access.ok) { + return access.response; + } + + const { caseId } = await context.params; + const repository = getRepositoryFromRuntime(); + const optimizationCase = await repository.archiveOptimizationCase(caseId); + if (!optimizationCase) { + return NextResponse.json({ error: "Case not found" }, { status: 404 }); + } + + return NextResponse.json({ case: optimizationCase }); +} diff --git a/src/app/api/cases/[caseId]/rerun/route.ts b/src/app/api/cases/[caseId]/rerun/route.ts new file mode 100644 index 0000000..75083b8 --- /dev/null +++ b/src/app/api/cases/[caseId]/rerun/route.ts @@ -0,0 +1,298 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { requireApiAccess } from "../../../../../lib/api/auth"; +import type { + ArticleCaseInputPayload, + OptimizationCaseDetail, + ProcessSummaryStep, +} from "../../../../../lib/cases/types"; +import { createProcessStep, excerpt } from "../../../../../lib/cases/summaries"; +import { + getRepositoryFromRuntime, + type AppRepository, +} from "../../../../../lib/db/repository"; +import { + articleInputSchema, + copyOptimizationRequestSchema, + optimizationFactCardSchema, +} from "../../../../../lib/domain/validation"; +import type { LlmAuditSummary } from "../../../../../lib/llm/audit"; +import { LlmValidationError } from "../../../../../lib/llm/client"; +import { getExportStoreFromRuntime } from "../../../../../lib/workflow/export-store"; +import { extractCandidateFactCard } from "../../../../../lib/workflow/fact-extractor"; +import { runOptimizationWorkflow } from "../../../../../lib/workflow/orchestrator"; +import { optimizeRenweiCopy } from "../../../../../lib/workflow/renwei-copy-optimizer"; + +interface RouteContext { + params: Promise<{ caseId: string }>; +} + +export async function POST(request: Request, context: RouteContext) { + const access = requireApiAccess(request); + if (!access.ok) { + return access.response; + } + + const { caseId } = await context.params; + const repository = getRepositoryFromRuntime(); + const detail = await repository.getOptimizationCaseDetail(caseId); + if (!detail?.input) { + return NextResponse.json({ error: "Case not found" }, { status: 404 }); + } + const caseDetail = { ...detail, input: detail.input } satisfies CaseDetailWithInput; + + if (detail.case.case_type === "article") { + return rerunArticleCase(repository, caseDetail); + } + + if (detail.case.case_type === "human_copy") { + return rerunHumanCopyCase(repository, caseDetail); + } + + return NextResponse.json({ error: "Unsupported case type" }, { status: 400 }); +} + +type CaseDetailWithInput = OptimizationCaseDetail & { + input: NonNullable; +}; + +async function rerunHumanCopyCase( + repository: AppRepository, + detail: CaseDetailWithInput, +) { + const payload = copyOptimizationRequestSchema.parse(detail.input.payload); + const startedAt = Date.now(); + const llmAuditSummary: LlmAuditSummary[] = []; + + try { + const result = await optimizeRenweiCopy(payload, { + onAuditSummary: (summary) => llmAuditSummary.push(summary), + }); + const resultVersion = await repository.createOptimizationResultVersion({ + case_id: detail.case.id, + case_type: "human_copy", + status: "optimized", + article_job_id: null, + article_revision: null, + result_summary: excerpt(result.optimized_text), + payload: result, + process_summary: [ + createProcessStep({ + stage: "human_copy_optimize", + startedAt, + endedAt: Date.now(), + status: "success", + producedResultVersion: true, + }), + ], + llm_audit_summary: llmAuditSummary, + error_stage: null, + error_summary: null, + }); + + return NextResponse.json( + { + case: { id: detail.case.id, case_type: "human_copy" }, + result_version: { id: resultVersion.id, version: resultVersion.version }, + result, + }, + { status: 201 }, + ); + } catch (error) { + const message = errorMessage(error); + const resultVersion = await repository.createOptimizationResultVersion({ + case_id: detail.case.id, + case_type: "human_copy", + status: "failed", + article_job_id: null, + article_revision: null, + result_summary: "", + payload: null, + process_summary: [ + createProcessStep({ + stage: "human_copy_optimize", + startedAt, + endedAt: Date.now(), + status: "failed", + errorSummary: message, + producedResultVersion: false, + }), + ], + llm_audit_summary: llmAuditSummary, + error_stage: "human_copy_optimize", + error_summary: message, + }); + + return NextResponse.json( + { + error: message, + case: { id: detail.case.id, case_type: "human_copy" }, + result_version: { id: resultVersion.id, version: resultVersion.version }, + }, + { status: getErrorStatus(error) }, + ); + } +} + +function errorMessage(error: unknown) { + return error instanceof z.ZodError + ? "请输入需要优化的文案" + : error instanceof Error + ? error.message + : "文案优化失败"; +} + +function getErrorStatus(error: unknown) { + if (error instanceof LlmValidationError) return 502; + if (error instanceof Error && /^LLM\b|provider/i.test(error.message)) return 502; + return 400; +} + +async function rerunArticleCase( + repository: AppRepository, + detail: CaseDetailWithInput, +) { + const payload = detail.input.payload as ArticleCaseInputPayload; + const articleInput = articleInputSchema.parse({ + title: payload.source_title, + body: payload.source_body, + images: payload.image_inputs, + platform: payload.publish_platform, + user_instructions: payload.user_instructions, + }); + const llmAuditSummary: LlmAuditSummary[] = []; + const processSummary: ProcessSummaryStep[] = []; + const requestStartedAt = Date.now(); + let jobId: string | null = null; + let stage = "job"; + + try { + const job = await repository.createArticleJob({ + case_id: detail.case.id, + source_title: articleInput.title, + source_body: articleInput.body, + image_inputs: articleInput.images, + publish_platform: articleInput.platform, + user_instructions: articleInput.user_instructions, + }); + jobId = job.id; + + stage = "fact_card"; + const factCardStartedAt = Date.now(); + const existingFactCard = detail.input.article_job_id + ? await repository.getFactCard(detail.input.article_job_id) + : null; + const factCard = optimizationFactCardSchema.parse( + payload.fact_card ?? + existingFactCard ?? + (await extractCandidateFactCard(articleInput, { + onAuditSummary: (summary) => llmAuditSummary.push(summary), + })), + ); + processSummary.push( + createProcessStep({ + stage: "fact_card", + startedAt: factCardStartedAt, + endedAt: Date.now(), + status: "success", + producedResultVersion: false, + }), + ); + const savedFactCard = await repository.saveFactCard(job.id, factCard); + + stage = "optimize"; + const result = await runOptimizationWorkflow({ + input: articleInput, + factCard: savedFactCard, + onAuditSummary: (summary) => llmAuditSummary.push(summary), + }); + const optimizedArticle = await repository.saveOptimizedArticle( + job.id, + result.article, + ); + const qaReport = await repository.saveQaReport( + job.id, + optimizedArticle.revision ?? 1, + result.qaReport, + ); + const exportStore = getExportStoreFromRuntime(); + const exportPaths = await exportStore.writeJobExports({ + jobId: job.id, + article: optimizedArticle, + qaReport, + }); + await repository.updateArticleJob(job.id, { + status: "optimized", + export_paths: exportPaths, + }); + const resultVersion = await repository.createOptimizationResultVersion({ + case_id: detail.case.id, + case_type: "article", + status: "optimized", + article_job_id: job.id, + article_revision: optimizedArticle.revision ?? 1, + result_summary: optimizedArticle.summary, + payload: { + article: optimizedArticle, + qa_report: qaReport, + export_paths: exportPaths, + }, + process_summary: processSummary, + llm_audit_summary: llmAuditSummary, + error_stage: null, + error_summary: null, + }); + + return NextResponse.json( + { + case: { id: detail.case.id, case_type: "article" }, + result_version: { id: resultVersion.id, version: resultVersion.version }, + optimizedArticle, + qaReport, + exportPaths, + rewriteRounds: result.rewrite_rounds, + stoppedAfterMaxRewrites: result.stopped_after_max_rewrites, + timing: result.timing, + }, + { status: 201 }, + ); + } catch (error) { + const message = error instanceof Error ? error.message : "文章优化失败"; + const resultVersion = await repository.createOptimizationResultVersion({ + case_id: detail.case.id, + case_type: "article", + status: "failed", + article_job_id: jobId, + article_revision: null, + result_summary: "", + payload: null, + process_summary: [ + ...processSummary, + createProcessStep({ + stage, + startedAt: requestStartedAt, + endedAt: Date.now(), + status: "failed", + errorSummary: message, + producedResultVersion: false, + }), + ], + llm_audit_summary: llmAuditSummary, + error_stage: stage, + error_summary: message, + }); + if (jobId) { + await repository.updateArticleJob(jobId, { status: "failed" }); + } + + return NextResponse.json( + { + error: message, + case: { id: detail.case.id, case_type: "article" }, + result_version: { id: resultVersion.id, version: resultVersion.version }, + }, + { status: getErrorStatus(error) }, + ); + } +} diff --git a/src/app/api/cases/[caseId]/restore/route.ts b/src/app/api/cases/[caseId]/restore/route.ts new file mode 100644 index 0000000..4be0801 --- /dev/null +++ b/src/app/api/cases/[caseId]/restore/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from "next/server"; + +import { requireApiAccess } from "../../../../../lib/api/auth"; +import { getRepositoryFromRuntime } from "../../../../../lib/db/repository"; + +interface RouteContext { + params: Promise<{ caseId: string }>; +} + +export async function POST(request: Request, context: RouteContext) { + const access = requireApiAccess(request); + if (!access.ok) { + return access.response; + } + + const { caseId } = await context.params; + const repository = getRepositoryFromRuntime(); + const optimizationCase = await repository.restoreOptimizationCase(caseId); + if (!optimizationCase) { + return NextResponse.json({ error: "Case not found" }, { status: 404 }); + } + + return NextResponse.json({ case: optimizationCase }); +} diff --git a/src/app/api/cases/[caseId]/route.ts b/src/app/api/cases/[caseId]/route.ts new file mode 100644 index 0000000..620d352 --- /dev/null +++ b/src/app/api/cases/[caseId]/route.ts @@ -0,0 +1,45 @@ +import { NextResponse } from "next/server"; + +import { requireApiAccess } from "../../../../lib/api/auth"; +import { caseMetadataPatchSchema } from "../../../../lib/cases/validation"; +import { getRepositoryFromRuntime } from "../../../../lib/db/repository"; + +interface RouteContext { + params: Promise<{ caseId: string }>; +} + +export async function GET(request: Request, context: RouteContext) { + const access = requireApiAccess(request); + if (!access.ok) { + return access.response; + } + + const { caseId } = await context.params; + const repository = getRepositoryFromRuntime(); + const detail = await repository.getOptimizationCaseDetail(caseId); + if (!detail) { + return NextResponse.json({ error: "Case not found" }, { status: 404 }); + } + + return NextResponse.json(detail); +} + +export async function PATCH(request: Request, context: RouteContext) { + const access = requireApiAccess(request); + if (!access.ok) { + return access.response; + } + + const { caseId } = await context.params; + const repository = getRepositoryFromRuntime(); + const changes = caseMetadataPatchSchema.parse(await request.json()); + const optimizationCase = await repository.updateOptimizationCaseMetadata( + caseId, + changes, + ); + if (!optimizationCase) { + return NextResponse.json({ error: "Case not found" }, { status: 404 }); + } + + return NextResponse.json({ case: optimizationCase }); +} diff --git a/src/app/api/cases/[caseId]/versions/[versionId]/publications/route.ts b/src/app/api/cases/[caseId]/versions/[versionId]/publications/route.ts new file mode 100644 index 0000000..8eb033d --- /dev/null +++ b/src/app/api/cases/[caseId]/versions/[versionId]/publications/route.ts @@ -0,0 +1,63 @@ +import { NextResponse } from "next/server"; + +import { requireApiAccess } from "../../../../../../../lib/api/auth"; +import { resultVersionPublicationInputSchema } from "../../../../../../../lib/cases/validation"; +import { getRepositoryFromRuntime } from "../../../../../../../lib/db/repository"; + +interface RouteContext { + params: Promise<{ caseId: string; versionId: string }>; +} + +export async function GET(request: Request, context: RouteContext) { + const access = requireApiAccess(request); + if (!access.ok) { + return access.response; + } + + const { caseId, versionId } = await context.params; + const repository = getRepositoryFromRuntime(); + const version = await repository.getOptimizationResultVersion(versionId); + if (!version || version.case_id !== caseId) { + return NextResponse.json( + { error: "Result version not found" }, + { status: 404 }, + ); + } + + return NextResponse.json({ + publications: await repository.listPublicationRecordsForResultVersion( + version.id, + ), + }); +} + +export async function POST(request: Request, context: RouteContext) { + const access = requireApiAccess(request); + if (!access.ok) { + return access.response; + } + + const { caseId, versionId } = await context.params; + const repository = getRepositoryFromRuntime(); + const version = await repository.getOptimizationResultVersion(versionId); + if (!version || version.case_id !== caseId) { + return NextResponse.json( + { error: "Result version not found" }, + { status: 404 }, + ); + } + + const input = resultVersionPublicationInputSchema.parse(await request.json()); + const publication = await repository.createPublicationRecord({ + result_version_id: version.id, + job_id: version.article_job_id, + revision: version.article_revision, + publish_target: input.publish_target, + url: input.url, + published_at: input.published_at, + status: "published", + notes: input.notes, + }); + + return NextResponse.json({ publication }, { status: 201 }); +} diff --git a/src/app/api/cases/route.ts b/src/app/api/cases/route.ts new file mode 100644 index 0000000..e72c5fb --- /dev/null +++ b/src/app/api/cases/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from "next/server"; + +import { requireApiAccess } from "../../../lib/api/auth"; +import { caseListFiltersSchema } from "../../../lib/cases/validation"; +import { getRepositoryFromRuntime } from "../../../lib/db/repository"; + +export async function GET(request: Request) { + const access = requireApiAccess(request); + if (!access.ok) { + return access.response; + } + + const url = new URL(request.url); + const filters = caseListFiltersSchema.parse( + Object.fromEntries(url.searchParams.entries()), + ); + const repository = getRepositoryFromRuntime(); + + return NextResponse.json({ + cases: await repository.listOptimizationCases(filters), + }); +} diff --git a/src/app/api/publications/[publicationId]/performance/route.ts b/src/app/api/publications/[publicationId]/performance/route.ts index 6be6470..0a763ce 100644 --- a/src/app/api/publications/[publicationId]/performance/route.ts +++ b/src/app/api/publications/[publicationId]/performance/route.ts @@ -22,12 +22,22 @@ export async function POST(request: Request, context: RouteContext) { return NextResponse.json({ error: "Publication not found" }, { status: 404 }); } - const scoringRun = await repository.getLatestScoringRun( - publication.job_id, - publication.revision, - ); - const qaReport = await repository.getLatestQaReport(publication.job_id); - if (!scoringRun || !qaReport) { + const scoringRun = publication.result_version_id + ? await repository.getLatestScoringRunForResultVersion( + publication.result_version_id, + ) + : publication.job_id && publication.revision + ? await repository.getLatestScoringRun( + publication.job_id, + publication.revision, + ) + : null; + const qaReport = publication.job_id + ? await repository.getLatestQaReport(publication.job_id) + : null; + const manualInput = await request.json(); + + if (!publication.result_version_id && (!scoringRun || !qaReport)) { return NextResponse.json( { error: "Score the optimized revision before recording performance" }, { status: 409 }, @@ -40,9 +50,15 @@ export async function POST(request: Request, context: RouteContext) { await adapter.fetch({ publication, window_label: "manual", - manualInput: await request.json(), + manualInput, }), ); + if (!scoringRun || !qaReport) { + return NextResponse.json( + { snapshot, calibrationEvent: null }, + { status: 201 }, + ); + } const calibrationEvent = await repository.saveCalibrationEvent( createCalibrationEvent({ scoringRun, qaReport, snapshot }), );