feat: add cloudflare workers deployment
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user