feat: add cloudflare workers deployment

This commit is contained in:
Codex
2026-06-21 23:43:02 +08:00
parent 2fc34979f4
commit 44e9e04533
35 changed files with 8343 additions and 121 deletions
+59 -10
View File
@@ -25,20 +25,51 @@ const validFactCard = {
confirmed_by_user: true,
};
interface CreateJobResponse {
job: { id: string };
candidateFactCard: { company_full_name: string };
}
interface OptimizeJobResponse {
optimizedArticle: { title: string };
qaReport: { checks: unknown[] };
}
describe("job API routes", () => {
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-agent-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;
rmSync(tempDir, { recursive: true, force: true });
});
it("rejects API requests without the access key", async () => {
const response = await createJob(
request(
{
title: "Example Technology Co., Ltd. GEO guide",
body: "Example Technology Co., Ltd. has 8 years of GEO optimization experience.",
platform: "official_site",
},
{ apiKey: null },
),
);
expect(response.status).toBe(401);
});
it("validates input, creates a job, and returns a candidate fact card", async () => {
const response = await createJob(
request({
@@ -49,7 +80,7 @@ describe("job API routes", () => {
user_instructions: "Keep factual",
}),
);
const body = await response.json();
const body = (await response.json()) as CreateJobResponse;
expect(response.status).toBe(201);
expect(body.job.id).toMatch(/^job_/);
@@ -66,7 +97,7 @@ describe("job API routes", () => {
uncertain_items: ["Need company confirmation"],
is_ready_for_optimization: false,
}),
params({ jobId: job.id }),
params<{ jobId: string }>({ jobId: job.id }),
);
expect(response.status).toBe(400);
@@ -74,17 +105,26 @@ describe("job API routes", () => {
it("rejects optimize requests for jobs without confirmed fact cards", async () => {
const { job } = await createJobFixture();
const response = await optimizeJob(request({}), params({ jobId: job.id }));
const response = await optimizeJob(
request({}),
params<{ jobId: string }>({ jobId: job.id }),
);
expect(response.status).toBe(409);
});
it("returns optimized article and QA report for successful optimization", async () => {
const { job } = await createJobFixture();
await confirmFactCard(request(validFactCard), params({ jobId: job.id }));
await confirmFactCard(
request(validFactCard),
params<{ jobId: string }>({ jobId: job.id }),
);
const response = await optimizeJob(request({}), params({ jobId: job.id }));
const body = await response.json();
const response = await optimizeJob(
request({}),
params<{ jobId: string }>({ jobId: job.id }),
);
const body = (await response.json()) as OptimizeJobResponse;
expect(response.status).toBe(200);
expect(body.optimizedArticle.title).toContain("GEO optimization");
@@ -99,7 +139,10 @@ describe("job API routes", () => {
const response = await downloadExport(
request({}),
params({ jobId: job.id, fileName: "unknown.txt" }),
params<{ jobId: string; fileName: string }>({
jobId: job.id,
fileName: "unknown.txt",
}),
);
expect(response.status).toBe(404);
@@ -119,14 +162,20 @@ async function createJobFixture() {
return response.json() as Promise<{ job: { id: string } }>;
}
function request(body: unknown) {
function request(body: unknown, options: { apiKey?: string | null } = {}) {
const headers: Record<string, string> = { "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/jobs", {
method: "POST",
body: JSON.stringify(body),
headers: { "content-type": "application/json" },
headers,
});
}
function params(values: Record<string, string>) {
function params<T extends Record<string, string>>(values: T) {
return { params: Promise.resolve(values) };
}
@@ -1,11 +1,7 @@
import { NextResponse } from "next/server";
import {
createBrandTemplate,
getArticleJob,
saveFactCard,
updateArticleJob,
} from "../../../../../lib/db/repositories";
import { requireApiAccess } from "../../../../../lib/api/auth";
import { getRepositoryFromRuntime } from "../../../../../lib/db/repository";
import { confirmedFactCardSchema } from "../../../../../lib/domain/validation";
interface RouteContext {
@@ -13,16 +9,25 @@ interface RouteContext {
}
export async function POST(request: Request, context: RouteContext) {
const access = requireApiAccess(request);
if (!access.ok) {
return access.response;
}
const { jobId } = await context.params;
const job = getArticleJob(undefined, jobId);
const repository = getRepositoryFromRuntime();
const job = await repository.getArticleJob(jobId);
if (!job) {
return NextResponse.json({ error: "Job not found" }, { status: 404 });
}
try {
const factCard = confirmedFactCardSchema.parse(await request.json());
const brandTemplate = createBrandTemplate(undefined, {
brand_name: factCard.brand_names[0] ?? factCard.company_short_names[0] ?? factCard.company_full_name,
const brandTemplate = await repository.createBrandTemplate({
brand_name:
factCard.brand_names[0] ??
factCard.company_short_names[0] ??
factCard.company_full_name,
company_full_name: factCard.company_full_name,
company_short_names: factCard.company_short_names,
product_names: factCard.product_names,
@@ -35,8 +40,8 @@ export async function POST(request: Request, context: RouteContext) {
media_article: "objective third-party voice",
},
});
const savedFactCard = saveFactCard(undefined, jobId, factCard);
updateArticleJob(undefined, jobId, {
const savedFactCard = await repository.saveFactCard(jobId, factCard);
await repository.updateArticleJob(jobId, {
brand_template_id: brandTemplate.id,
status: "fact_confirmed",
});
@@ -1,37 +1,24 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { NextResponse } from "next/server";
import { getAppDataDir } from "../../../../../../lib/db/connection";
const CONTENT_TYPES: Record<string, string> = {
"optimized.md": "text/markdown; charset=utf-8",
"optimized.docx":
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"qa_report.json": "application/json; charset=utf-8",
};
import { requireApiAccess } from "../../../../../../lib/api/auth";
import { getExportStoreFromRuntime } from "../../../../../../lib/workflow/export-store";
interface RouteContext {
params: Promise<{ jobId: string; fileName: string }>;
}
export async function GET(_request: Request, context: RouteContext) {
export async function GET(request: Request, context: RouteContext) {
const access = requireApiAccess(request);
if (!access.ok) {
return access.response;
}
const { jobId, fileName } = await context.params;
const contentType = CONTENT_TYPES[fileName];
if (!contentType) {
const exportStore = getExportStoreFromRuntime();
const response = await exportStore.readJobExport(jobId, fileName);
if (!response) {
return NextResponse.json({ error: "Export file not found" }, { status: 404 });
}
const path = join(getAppDataDir(), "exports", jobId, fileName);
if (!existsSync(path)) {
return NextResponse.json({ error: "Export file not found" }, { status: 404 });
}
return new Response(readFileSync(path), {
headers: {
"content-type": contentType,
"content-disposition": `attachment; filename="${fileName}"`,
},
});
return response;
}
+17 -16
View File
@@ -1,27 +1,28 @@
import { NextResponse } from "next/server";
import {
getArticleJob,
getFactCard,
saveOptimizedArticle,
saveQaReport,
updateArticleJob,
} from "../../../../../lib/db/repositories";
import { writeJobExports } from "../../../../../lib/workflow/exporter";
import { requireApiAccess } from "../../../../../lib/api/auth";
import { getRepositoryFromRuntime } from "../../../../../lib/db/repository";
import { getExportStoreFromRuntime } from "../../../../../lib/workflow/export-store";
import { runOptimizationWorkflow } from "../../../../../lib/workflow/orchestrator";
interface RouteContext {
params: Promise<{ jobId: string }>;
}
export async function POST(_request: Request, context: RouteContext) {
export async function POST(request: Request, context: RouteContext) {
const access = requireApiAccess(request);
if (!access.ok) {
return access.response;
}
const { jobId } = await context.params;
const job = getArticleJob(undefined, jobId);
const repository = getRepositoryFromRuntime();
const job = await repository.getArticleJob(jobId);
if (!job) {
return NextResponse.json({ error: "Job not found" }, { status: 404 });
}
const factCardRecord = getFactCard(undefined, jobId);
const factCardRecord = await repository.getFactCard(jobId);
if (!factCardRecord) {
return NextResponse.json(
{ error: "Confirm the fact card before optimizing" },
@@ -39,22 +40,22 @@ export async function POST(_request: Request, context: RouteContext) {
},
factCard: factCardRecord,
});
const optimizedArticle = saveOptimizedArticle(undefined, jobId, result.article);
const qaReport = saveQaReport(
undefined,
const optimizedArticle = await repository.saveOptimizedArticle(jobId, result.article);
const qaReport = await repository.saveQaReport(
jobId,
optimizedArticle.revision ?? 1,
result.qaReport,
);
const exportStore = getExportStoreFromRuntime();
const exportPaths =
qaReport.overall_status === "fail"
? {}
: await writeJobExports({
: await exportStore.writeJobExports({
jobId,
article: optimizedArticle,
qaReport,
});
updateArticleJob(undefined, jobId, {
await repository.updateArticleJob(jobId, {
status: qaReport.overall_status === "fail" ? "qa_failed" : "optimized",
export_paths: exportPaths,
});
+11 -4
View File
@@ -1,14 +1,21 @@
import { NextResponse } from "next/server";
import { createArticleJob } from "../../../lib/db/repositories";
import { requireApiAccess } from "../../../lib/api/auth";
import { getRepositoryFromRuntime } from "../../../lib/db/repository";
import { extractCandidateFactCard } from "../../../lib/workflow/fact-extractor";
import { normalizeInput } from "../../../lib/workflow/input-normalizer";
import { normalizeInput, type RawArticleInput } from "../../../lib/workflow/input-normalizer";
export async function POST(request: Request) {
const access = requireApiAccess(request);
if (!access.ok) {
return access.response;
}
try {
const payload = await request.json();
const payload = (await request.json()) as RawArticleInput;
const normalized = normalizeInput(payload);
const job = createArticleJob(undefined, {
const repository = getRepositoryFromRuntime();
const job = await repository.createArticleJob({
source_title: normalized.articleInput.title,
source_body: normalized.articleInput.body,
image_inputs: normalized.articleInput.images,
+4
View File
@@ -104,6 +104,10 @@ h3 {
color: #586174;
}
.api-key-field {
min-width: min(16rem, 100%);
}
.workflow-grid {
display: grid;
gap: 1rem;
+42 -5
View File
@@ -26,6 +26,24 @@ const initialInput: ArticleInputPayload = {
user_instructions: "",
};
interface ApiErrorResponse {
error?: string;
}
interface CreateJobResponse extends ApiErrorResponse {
job: { id: string };
candidateFactCard: CandidateFactCard;
}
interface ConfirmFactCardResponse extends ApiErrorResponse {
factCard: CandidateFactCard;
}
interface OptimizeJobResponse extends ApiErrorResponse {
optimizedArticle: OptimizedArticle;
qaReport: QaReport;
}
export default function Home() {
const [input, setInput] = useState(initialInput);
const [jobId, setJobId] = useState<string | null>(null);
@@ -35,6 +53,7 @@ export default function Home() {
const [qaReport, setQaReport] = useState<QaReport | null>(null);
const [busyAction, setBusyAction] = useState<string | null>(null);
const [message, setMessage] = useState<string>("");
const [apiAccessKey, setApiAccessKey] = useState("");
const exportBlocked = useMemo(
() => qaReport?.overall_status === "fail",
@@ -51,10 +70,10 @@ export default function Home() {
try {
const response = await fetch("/api/jobs", {
method: "POST",
headers: { "content-type": "application/json" },
headers: apiHeaders(apiAccessKey),
body: JSON.stringify(input),
});
const body = await response.json();
const body = (await response.json()) as CreateJobResponse;
if (!response.ok) throw new Error(body.error ?? "分析失败");
setJobId(body.job.id);
setFactCard(body.candidateFactCard);
@@ -73,10 +92,10 @@ export default function Home() {
try {
const response = await fetch(`/api/jobs/${jobId}/confirm-fact-card`, {
method: "POST",
headers: { "content-type": "application/json" },
headers: apiHeaders(apiAccessKey),
body: JSON.stringify(toConfirmedFactCard(factCard)),
});
const body = await response.json();
const body = (await response.json()) as ConfirmFactCardResponse;
if (!response.ok) throw new Error(body.error ?? "确认失败");
setFactCard(body.factCard);
setMessage("事实卡已确认。");
@@ -94,8 +113,9 @@ export default function Home() {
try {
const response = await fetch(`/api/jobs/${jobId}/optimize`, {
method: "POST",
headers: apiHeaders(apiAccessKey),
});
const body = await response.json();
const body = (await response.json()) as OptimizeJobResponse;
if (!response.ok) throw new Error(body.error ?? "优化失败");
setOptimizedArticle(body.optimizedArticle);
setQaReport(body.qaReport);
@@ -118,6 +138,15 @@ export default function Home() {
<h1>GEO </h1>
{message && <p>{message}</p>}
</div>
<label className="api-key-field">
<span>访</span>
<input
autoComplete="off"
onChange={(event) => setApiAccessKey(event.target.value)}
type="password"
value={apiAccessKey}
/>
</label>
<button
disabled={!canOptimize || busyAction === "optimize"}
onClick={optimize}
@@ -149,3 +178,11 @@ export default function Home() {
</main>
);
}
function apiHeaders(apiAccessKey: string) {
const headers: Record<string, string> = { "content-type": "application/json" };
if (apiAccessKey) {
headers["x-api-key"] = apiAccessKey;
}
return headers;
}
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, test } from "vitest";
import { requireApiAccess } from "../auth";
describe("requireApiAccess", () => {
test("allows local test requests when auth is explicitly disabled", () => {
const request = new Request("http://localhost/api/jobs");
const result = requireApiAccess(request, {
apiAccessKey: undefined,
authDisabled: true,
});
expect(result.ok).toBe(true);
});
test("rejects requests when the configured key is missing", () => {
const request = new Request("http://localhost/api/jobs");
const result = requireApiAccess(request, {
apiAccessKey: "secret",
authDisabled: false,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.response.status).toBe(401);
}
});
test("rejects requests with the wrong key", () => {
const request = new Request("http://localhost/api/jobs", {
headers: { "x-api-key": "wrong" },
});
const result = requireApiAccess(request, {
apiAccessKey: "secret",
authDisabled: false,
});
expect(result.ok).toBe(false);
});
test("allows requests with the correct key", () => {
const request = new Request("http://localhost/api/jobs", {
headers: { "x-api-key": "secret" },
});
const result = requireApiAccess(request, {
apiAccessKey: "secret",
authDisabled: false,
});
expect(result.ok).toBe(true);
});
});
+48
View File
@@ -0,0 +1,48 @@
import { NextResponse } from "next/server";
import { getAppCloudflareEnv } from "../runtime/cloudflare";
interface ApiAccessOptions {
apiAccessKey?: string;
authDisabled?: boolean;
}
type ApiAccessResult =
| { ok: true }
| { ok: false; response: NextResponse<{ error: string }> };
export function requireApiAccess(
request: Request,
options: ApiAccessOptions = {
apiAccessKey: getConfiguredApiAccessKey(),
authDisabled: process.env.API_AUTH_DISABLED === "true",
},
): ApiAccessResult {
if (options.authDisabled) {
return { ok: true };
}
if (!options.apiAccessKey) {
return {
ok: false,
response: NextResponse.json(
{ error: "API access key is not configured" },
{ status: 401 },
),
};
}
const provided = request.headers.get("x-api-key");
if (provided !== options.apiAccessKey) {
return {
ok: false,
response: NextResponse.json({ error: "Unauthorized" }, { status: 401 }),
};
}
return { ok: true };
}
function getConfiguredApiAccessKey() {
return getAppCloudflareEnv()?.API_ACCESS_KEY ?? process.env.API_ACCESS_KEY;
}
@@ -0,0 +1,65 @@
import { describe, expect, test, vi } from "vitest";
import { createD1Repository } from "../d1-repository";
describe("createD1Repository", () => {
test("creates an article job using D1 prepare and bind", async () => {
const run = vi.fn().mockResolvedValue({ success: true });
const bind = vi.fn().mockReturnValue({ run });
const prepare = vi.fn().mockReturnValue({ bind });
const db = { prepare } as unknown as D1Database;
const repository = createD1Repository(db);
const job = await repository.createArticleJob({
source_title: "Title",
source_body: "Body",
image_inputs: [],
publish_platform: "official_site",
user_instructions: "",
});
expect(job.id).toMatch(/^job_/);
expect(prepare).toHaveBeenCalledWith(expect.stringContaining("insert into article_jobs"));
expect(bind).toHaveBeenCalledWith(
job.id,
null,
"Title",
"Body",
"[]",
"official_site",
"",
"draft",
"{}",
job.created_at,
job.updated_at,
);
});
test("parses article job JSON fields returned from D1", async () => {
const first = vi.fn().mockResolvedValue({
id: "job_123",
brand_template_id: null,
source_title: "Title",
source_body: "Body",
image_inputs: "[]",
publish_platform: "official_site",
user_instructions: "",
status: "draft",
export_paths: "{}",
created_at: "2026-06-16T00:00:00.000Z",
updated_at: "2026-06-16T00:00:00.000Z",
});
const bind = vi.fn().mockReturnValue({ first });
const prepare = vi.fn().mockReturnValue({ bind });
const db = { prepare } as unknown as D1Database;
const repository = createD1Repository(db);
await expect(repository.getArticleJob("job_123")).resolves.toMatchObject({
id: "job_123",
image_inputs: [],
export_paths: {},
});
});
});
@@ -0,0 +1,23 @@
import { describe, expect, test } from "vitest";
import { getRepositoryFromRuntime } from "../repository";
describe("getRepositoryFromRuntime", () => {
test("returns local repository when APP_RUNTIME is not cloudflare", () => {
const repository = getRepositoryFromRuntime({
appRuntime: "local",
dbPath: ":memory:",
});
expect(repository).toBeDefined();
});
test("throws clearly for cloudflare runtime without D1 binding", () => {
expect(() =>
getRepositoryFromRuntime({
appRuntime: "cloudflare",
cloudflareEnv: {},
}),
).toThrow("Cloudflare D1 binding DB is required");
});
});
+39
View File
@@ -0,0 +1,39 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { createSqliteRepository } from "../sqlite-repository";
describe("createSqliteRepository", () => {
let tempDir: string;
let dbPath: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "geo-repository-"));
dbPath = join(tempDir, "app.db");
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
test("creates and reads an article job through the async repository interface", async () => {
const repository = createSqliteRepository(dbPath);
const job = await repository.createArticleJob({
source_title: "Title",
source_body: "Body",
image_inputs: [],
publish_platform: "official_site",
user_instructions: "",
});
await expect(repository.getArticleJob(job.id)).resolves.toMatchObject({
id: job.id,
source_title: "Title",
export_paths: {},
});
});
});
+303
View File
@@ -0,0 +1,303 @@
import { nanoid } from "nanoid";
import type {
ConfirmedFactCard,
ImageInput,
OptimizedArticle,
PublishPlatform,
QaReport,
} from "../domain/types";
import type { AppRepository } from "./repository";
import type { ArticleJob, BrandTemplate } from "./repositories";
type JsonObject = Record<string, unknown>;
interface BrandTemplateRow {
id: string;
brand_name: string;
company_full_name: string;
company_short_names: string;
product_names: string;
target_industries: string;
target_audience: string;
verified_claims: string;
forbidden_claims: string;
tone_rules: string;
created_at: string;
updated_at: string;
}
interface ArticleJobRow {
id: string;
brand_template_id: string | null;
source_title: string;
source_body: string;
image_inputs: string;
publish_platform: PublishPlatform;
user_instructions: string;
status: string;
export_paths: string;
created_at: string;
updated_at: string;
}
interface FactCardRow {
job_id: string;
fact_card: string;
}
interface OptimizedArticleRow {
job_id: string;
revision: number;
article: string;
}
interface QaReportRow {
job_id: string;
revision: number;
report: string;
}
function nowIso() {
return new Date().toISOString();
}
function serialize(value: unknown) {
return JSON.stringify(value);
}
function parseJson<T>(value: string): T {
return JSON.parse(value) as T;
}
function toBrandTemplate(row: BrandTemplateRow): BrandTemplate {
return {
...row,
company_short_names: parseJson<string[]>(row.company_short_names),
product_names: parseJson<string[]>(row.product_names),
target_industries: parseJson<string[]>(row.target_industries),
target_audience: parseJson<string[]>(row.target_audience),
verified_claims: parseJson<string[]>(row.verified_claims),
forbidden_claims: parseJson<string[]>(row.forbidden_claims),
tone_rules: parseJson<JsonObject>(row.tone_rules),
};
}
function toArticleJob(row: ArticleJobRow): ArticleJob {
return {
...row,
image_inputs: parseJson<ImageInput[]>(row.image_inputs),
export_paths: parseJson<Record<string, string>>(row.export_paths),
};
}
export function createD1Repository(db: D1Database): AppRepository {
return {
async createBrandTemplate(input) {
const createdAt = nowIso();
const template: BrandTemplate = {
id: `brand_${nanoid(10)}`,
...input,
created_at: createdAt,
updated_at: createdAt,
};
await db
.prepare(
`insert into brand_templates (
id, brand_name, company_full_name, company_short_names, product_names,
target_industries, target_audience, verified_claims, forbidden_claims,
tone_rules, created_at, updated_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
template.id,
template.brand_name,
template.company_full_name,
serialize(template.company_short_names),
serialize(template.product_names),
serialize(template.target_industries),
serialize(template.target_audience),
serialize(template.verified_claims),
serialize(template.forbidden_claims),
serialize(template.tone_rules),
template.created_at,
template.updated_at,
)
.run();
return template;
},
async listBrandTemplates() {
const result = await db
.prepare("select * from brand_templates order by updated_at desc")
.all<BrandTemplateRow>();
return result.results.map(toBrandTemplate);
},
async getBrandTemplate(id) {
const row = await db
.prepare("select * from brand_templates where id = ?")
.bind(id)
.first<BrandTemplateRow>();
return row ? toBrandTemplate(row) : null;
},
async createArticleJob(input) {
const createdAt = nowIso();
const job: ArticleJob = {
id: `job_${nanoid(10)}`,
brand_template_id: input.brand_template_id ?? null,
source_title: input.source_title,
source_body: input.source_body,
image_inputs: input.image_inputs,
publish_platform: input.publish_platform,
user_instructions: input.user_instructions,
status: "draft",
export_paths: {},
created_at: createdAt,
updated_at: createdAt,
};
await db
.prepare(
`insert into article_jobs (
id, brand_template_id, source_title, source_body, image_inputs,
publish_platform, user_instructions, status, export_paths, created_at, updated_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
job.id,
job.brand_template_id,
job.source_title,
job.source_body,
serialize(job.image_inputs),
job.publish_platform,
job.user_instructions,
job.status,
serialize(job.export_paths),
job.created_at,
job.updated_at,
)
.run();
return job;
},
async getArticleJob(id) {
const row = await db
.prepare("select * from article_jobs where id = ?")
.bind(id)
.first<ArticleJobRow>();
return row ? toArticleJob(row) : null;
},
async updateArticleJob(id, changes) {
const existing = await this.getArticleJob(id);
if (!existing) return null;
const updated = {
brand_template_id: changes.brand_template_id ?? existing.brand_template_id,
status: changes.status ?? existing.status,
export_paths: changes.export_paths ?? existing.export_paths,
updated_at: nowIso(),
};
await db
.prepare(
`update article_jobs set
brand_template_id = ?,
status = ?,
export_paths = ?,
updated_at = ?
where id = ?`,
)
.bind(
updated.brand_template_id,
updated.status,
serialize(updated.export_paths),
updated.updated_at,
id,
)
.run();
return this.getArticleJob(id);
},
async saveFactCard(jobId, factCard) {
const timestamp = nowIso();
await db
.prepare(
`insert into fact_cards (
job_id, source, fact_card, confirmed_by_user, created_at, updated_at
) values (?, ?, ?, ?, ?, ?)
on conflict(job_id) do update set
fact_card = excluded.fact_card,
confirmed_by_user = excluded.confirmed_by_user,
updated_at = excluded.updated_at`,
)
.bind(
jobId,
"auto_extract_then_user_confirmed",
serialize(factCard),
factCard.confirmed_by_user ? 1 : 0,
timestamp,
timestamp,
)
.run();
return { job_id: jobId, ...factCard };
},
async getFactCard(jobId) {
const row = await db
.prepare("select job_id, fact_card from fact_cards where job_id = ?")
.bind(jobId)
.first<FactCardRow>();
return row
? { job_id: row.job_id, ...parseJson<ConfirmedFactCard>(row.fact_card) }
: null;
},
async saveOptimizedArticle(jobId, article) {
const latest = await this.getLatestOptimizedArticle(jobId);
const revision = (latest?.revision ?? 0) + 1;
const saved = { ...article, job_id: jobId, revision };
await db
.prepare(
`insert into optimized_articles (job_id, revision, article, created_at)
values (?, ?, ?, ?)`,
)
.bind(jobId, revision, serialize(saved), nowIso())
.run();
return saved;
},
async getLatestOptimizedArticle(jobId) {
const row = await db
.prepare(
`select job_id, revision, article
from optimized_articles
where job_id = ?
order by revision desc
limit 1`,
)
.bind(jobId)
.first<OptimizedArticleRow>();
return row ? parseJson<OptimizedArticle>(row.article) : null;
},
async saveQaReport(jobId, revision, report) {
const saved = { ...report, job_id: jobId, revision };
await db
.prepare(
`insert into qa_reports (job_id, revision, report, created_at)
values (?, ?, ?, ?)
on conflict(job_id, revision) do update set report = excluded.report`,
)
.bind(jobId, revision, serialize(saved), nowIso())
.run();
return saved;
},
async getLatestQaReport(jobId) {
const row = await db
.prepare(
`select job_id, revision, report
from qa_reports
where job_id = ?
order by revision desc
limit 1`,
)
.bind(jobId)
.first<QaReportRow>();
return row ? parseJson<QaReport>(row.report) : null;
},
};
}
+52
View File
@@ -0,0 +1,52 @@
import type { ConfirmedFactCard, OptimizedArticle, QaReport } from "../domain/types";
import type {
ArticleJob,
BrandTemplate,
NewArticleJob,
NewBrandTemplate,
} from "./repositories";
import { getAppCloudflareEnv, type AppCloudflareEnv } from "../runtime/cloudflare";
import { createD1Repository } from "./d1-repository";
import { createSqliteRepository } from "./sqlite-repository";
export interface AppRepository {
createBrandTemplate(input: NewBrandTemplate): Promise<BrandTemplate>;
listBrandTemplates(): Promise<BrandTemplate[]>;
getBrandTemplate(id: string): Promise<BrandTemplate | null>;
createArticleJob(input: NewArticleJob): Promise<ArticleJob>;
getArticleJob(id: string): Promise<ArticleJob | null>;
updateArticleJob(
id: string,
changes: Partial<Pick<ArticleJob, "brand_template_id" | "status" | "export_paths">>,
): Promise<ArticleJob | null>;
saveFactCard(
jobId: string,
factCard: ConfirmedFactCard,
): Promise<{ job_id: string } & ConfirmedFactCard>;
getFactCard(jobId: string): Promise<({ job_id: string } & ConfirmedFactCard) | null>;
saveOptimizedArticle(jobId: string, article: OptimizedArticle): Promise<OptimizedArticle>;
getLatestOptimizedArticle(jobId: string): Promise<OptimizedArticle | null>;
saveQaReport(jobId: string, revision: number, report: QaReport): Promise<QaReport>;
getLatestQaReport(jobId: string): Promise<QaReport | null>;
}
interface RuntimeRepositoryOptions {
appRuntime?: string;
dbPath?: string;
cloudflareEnv?: AppCloudflareEnv;
}
export function getRepositoryFromRuntime(
options: RuntimeRepositoryOptions = {},
): AppRepository {
const appRuntime = options.appRuntime ?? process.env.APP_RUNTIME;
if (appRuntime === "cloudflare") {
const env = options.cloudflareEnv ?? getAppCloudflareEnv();
if (!env?.DB) {
throw new Error("Cloudflare D1 binding DB is required");
}
return createD1Repository(env.DB);
}
return createSqliteRepository(options.dbPath);
}
+63
View File
@@ -0,0 +1,63 @@
import type { ConfirmedFactCard, OptimizedArticle, QaReport } from "../domain/types";
import type { AppRepository } from "./repository";
import {
createArticleJob,
createBrandTemplate,
getArticleJob,
getBrandTemplate,
getFactCard,
getLatestOptimizedArticle,
getLatestQaReport,
listBrandTemplates,
saveFactCard,
saveOptimizedArticle,
saveQaReport,
updateArticleJob,
type ArticleJob,
type NewArticleJob,
type NewBrandTemplate,
} from "./repositories";
export function createSqliteRepository(dbPath?: string): AppRepository {
return {
createBrandTemplate(input: NewBrandTemplate) {
return Promise.resolve(createBrandTemplate(dbPath, input));
},
listBrandTemplates() {
return Promise.resolve(listBrandTemplates(dbPath));
},
getBrandTemplate(id: string) {
return Promise.resolve(getBrandTemplate(dbPath, id));
},
createArticleJob(input: NewArticleJob) {
return Promise.resolve(createArticleJob(dbPath, input));
},
getArticleJob(id: string) {
return Promise.resolve(getArticleJob(dbPath, id));
},
updateArticleJob(
id: string,
changes: Partial<Pick<ArticleJob, "brand_template_id" | "status" | "export_paths">>,
) {
return Promise.resolve(updateArticleJob(dbPath, id, changes));
},
saveFactCard(jobId: string, factCard: ConfirmedFactCard) {
return Promise.resolve(saveFactCard(dbPath, jobId, factCard));
},
getFactCard(jobId: string) {
return Promise.resolve(getFactCard(dbPath, jobId));
},
saveOptimizedArticle(jobId: string, article: OptimizedArticle) {
return Promise.resolve(saveOptimizedArticle(dbPath, jobId, article));
},
getLatestOptimizedArticle(jobId: string) {
return Promise.resolve(getLatestOptimizedArticle(dbPath, jobId));
},
saveQaReport(jobId: string, revision: number, report: QaReport) {
return Promise.resolve(saveQaReport(dbPath, jobId, revision, report));
},
getLatestQaReport(jobId: string) {
return Promise.resolve(getLatestQaReport(dbPath, jobId));
},
};
}
+15
View File
@@ -0,0 +1,15 @@
import { getCloudflareContext } from "@opennextjs/cloudflare";
export interface AppCloudflareEnv {
DB?: D1Database;
EXPORT_BUCKET?: R2Bucket;
API_ACCESS_KEY?: string;
}
export function getAppCloudflareEnv(): AppCloudflareEnv | null {
if (process.env.APP_RUNTIME !== "cloudflare") {
return null;
}
return getCloudflareContext().env as AppCloudflareEnv;
}
@@ -0,0 +1,70 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import type { OptimizedArticle, QaReport } from "../../domain/types";
import { createLocalExportStore, createR2ExportStore } from "../export-store";
const article: OptimizedArticle = {
title: "Example",
summary: "Summary",
body_markdown: "Body",
image_suggestions: [],
changed_sections: [],
requires_user_confirmation: [],
revision: 1,
};
const report: QaReport = {
overall_status: "pass",
checks: [],
};
describe("export stores", () => {
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "geo-export-store-"));
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
test("local store writes and reads exports", async () => {
const store = createLocalExportStore(tempDir);
const paths = await store.writeJobExports({
jobId: "job_123",
article,
qaReport: report,
});
expect(paths.markdown).toContain("optimized.md");
const file = await store.readJobExport("job_123", "optimized.md");
expect(await file?.text()).toContain("# Example");
});
test("R2 store writes private export objects through binding", async () => {
const put = vi.fn().mockResolvedValue(undefined);
const bucket = { put } as unknown as R2Bucket;
const store = createR2ExportStore(bucket);
const paths = await store.writeJobExports({
jobId: "job_123",
article,
qaReport: report,
});
expect(paths.markdown).toBe("r2://exports/job_123/optimized.md");
expect(put).toHaveBeenCalledWith(
"exports/job_123/optimized.md",
expect.any(String),
expect.objectContaining({
httpMetadata: { contentType: "text/markdown; charset=utf-8" },
}),
);
});
});
+3 -2
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import type { ConfirmedFactCard } from "../../domain/types";
import { optimizeArticle } from "../article-optimizer";
import { extractCandidateFactCard } from "../fact-extractor";
import { normalizeInput } from "../input-normalizer";
@@ -7,7 +8,7 @@ import { inspectQuality } from "../quality-inspector";
import { runOptimizationWorkflow } from "../orchestrator";
import { rewriteFailedSections } from "../targeted-rewriter";
const confirmedFactCard = {
const confirmedFactCard: ConfirmedFactCard = {
company_full_name: "Example Technology Co., Ltd.",
company_short_names: ["Example Tech"],
brand_names: ["Example"],
@@ -21,7 +22,7 @@ const confirmedFactCard = {
uncertain_items: [],
is_ready_for_optimization: true,
confirmed_by_user: true,
} as const;
};
describe("workflow nodes", () => {
it("normalizes input whitespace and image lines", () => {
+115
View File
@@ -0,0 +1,115 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { getAppDataDir } from "../db/connection";
import type { OptimizedArticle, QaReport } from "../domain/types";
import { getAppCloudflareEnv } from "../runtime/cloudflare";
import {
renderOptimizedDocx,
renderOptimizedMarkdown,
renderQaReportJson,
} from "./exporter";
const CONTENT_TYPES: Record<string, string> = {
"optimized.md": "text/markdown; charset=utf-8",
"optimized.docx":
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"qa_report.json": "application/json; charset=utf-8",
};
export interface WriteJobExportsInput {
jobId: string;
article: OptimizedArticle;
qaReport: QaReport;
}
export interface ExportStore {
writeJobExports(input: WriteJobExportsInput): Promise<Record<string, string>>;
readJobExport(jobId: string, fileName: string): Promise<Response | null>;
}
export function createLocalExportStore(dataDir = getAppDataDir()): ExportStore {
return {
async writeJobExports({ jobId, article, qaReport }) {
const exportDir = join(dataDir, "exports", jobId);
mkdirSync(exportDir, { recursive: true });
const markdown = join(exportDir, "optimized.md");
const docx = join(exportDir, "optimized.docx");
const qaJson = join(exportDir, "qa_report.json");
writeFileSync(markdown, renderOptimizedMarkdown(article), "utf8");
writeFileSync(qaJson, renderQaReportJson(qaReport), "utf8");
writeFileSync(docx, await renderOptimizedDocx(article));
return { markdown, docx, qaJson };
},
async readJobExport(jobId, fileName) {
const contentType = CONTENT_TYPES[fileName];
if (!contentType) return null;
const path = join(dataDir, "exports", jobId, fileName);
if (!existsSync(path)) return null;
return new Response(readFileSync(path), {
headers: {
"content-type": contentType,
"content-disposition": `attachment; filename="${fileName}"`,
},
});
},
};
}
export function createR2ExportStore(bucket: R2Bucket): ExportStore {
return {
async writeJobExports({ jobId, article, qaReport }) {
const prefix = `exports/${jobId}`;
const markdownKey = `${prefix}/optimized.md`;
const docxKey = `${prefix}/optimized.docx`;
const qaJsonKey = `${prefix}/qa_report.json`;
await bucket.put(markdownKey, renderOptimizedMarkdown(article), {
httpMetadata: { contentType: CONTENT_TYPES["optimized.md"] },
});
await bucket.put(qaJsonKey, renderQaReportJson(qaReport), {
httpMetadata: { contentType: CONTENT_TYPES["qa_report.json"] },
});
await bucket.put(docxKey, await renderOptimizedDocx(article), {
httpMetadata: { contentType: CONTENT_TYPES["optimized.docx"] },
});
return {
markdown: `r2://${markdownKey}`,
docx: `r2://${docxKey}`,
qaJson: `r2://${qaJsonKey}`,
};
},
async readJobExport(jobId, fileName) {
const contentType = CONTENT_TYPES[fileName];
if (!contentType) return null;
const object = await bucket.get(`exports/${jobId}/${fileName}`);
if (!object) return null;
return new Response(object.body, {
headers: {
"content-type": object.httpMetadata?.contentType ?? contentType,
"content-disposition": `attachment; filename="${fileName}"`,
},
});
},
};
}
export function getExportStoreFromRuntime(): ExportStore {
if (process.env.APP_RUNTIME === "cloudflare") {
const env = getAppCloudflareEnv();
if (!env?.EXPORT_BUCKET) {
throw new Error("Cloudflare R2 binding EXPORT_BUCKET is required");
}
return createR2ExportStore(env.EXPORT_BUCKET);
}
return createLocalExportStore();
}