@topy-ai/maggie 0.7.32 → 0.7.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README-zh-TW.md +2 -2
- package/README.md +19 -4
- package/bundled-skills/maggie-blog-bootstrap/SKILL.md +8 -0
- package/bundled-skills/maggie-clone/SKILL.md +7 -1
- package/bundled-templates/astro-blog/README.md +217 -0
- package/bundled-templates/astro-blog/examples/blog.config.ts +30 -0
- package/bundled-templates/astro-blog/examples/content.config.ts +28 -0
- package/bundled-templates/astro-blog/examples/metadata.ts +37 -0
- package/bundled-templates/astro-blog/examples/ops-api.ts +31 -0
- package/bundled-templates/astro-blog/examples/route-manifest.ts +21 -0
- package/bundled-templates/astro-blog/starter/.env.example +36 -0
- package/bundled-templates/astro-blog/starter/README.md +168 -0
- package/bundled-templates/astro-blog/starter/astro.config.mjs +19 -0
- package/bundled-templates/astro-blog/starter/db/schema.sql +508 -0
- package/bundled-templates/astro-blog/starter/package-lock.json +8039 -0
- package/bundled-templates/astro-blog/starter/package.json +41 -0
- package/bundled-templates/astro-blog/starter/scripts/contract-check.mjs +16 -0
- package/bundled-templates/astro-blog/starter/scripts/fixture-seed.mjs +11 -0
- package/bundled-templates/astro-blog/starter/scripts/schema-check.mjs +13 -0
- package/bundled-templates/astro-blog/starter/scripts/seed-db.mjs +20 -0
- package/bundled-templates/astro-blog/starter/src/data/demo.ts +52 -0
- package/bundled-templates/astro-blog/starter/src/layouts/BaseLayout.astro +53 -0
- package/bundled-templates/astro-blog/starter/src/layouts/OpsLayout.astro +73 -0
- package/bundled-templates/astro-blog/starter/src/lib/ai-cmo.ts +14 -0
- package/bundled-templates/astro-blog/starter/src/lib/config.ts +15 -0
- package/bundled-templates/astro-blog/starter/src/lib/db.ts +95 -0
- package/bundled-templates/astro-blog/starter/src/lib/google.ts +28 -0
- package/bundled-templates/astro-blog/starter/src/lib/media.ts +12 -0
- package/bundled-templates/astro-blog/starter/src/lib/migration-validation.ts +11 -0
- package/bundled-templates/astro-blog/starter/src/lib/migration.ts +21 -0
- package/bundled-templates/astro-blog/starter/src/lib/ops-auth.ts +21 -0
- package/bundled-templates/astro-blog/starter/src/lib/providers/email.ts +45 -0
- package/bundled-templates/astro-blog/starter/src/lib/providers/media-storage.ts +37 -0
- package/bundled-templates/astro-blog/starter/src/lib/report-delivery.ts +26 -0
- package/bundled-templates/astro-blog/starter/src/lib/report-pdf.ts +49 -0
- package/bundled-templates/astro-blog/starter/src/lib/repository.ts +72 -0
- package/bundled-templates/astro-blog/starter/src/lib/seo-audit.ts +72 -0
- package/bundled-templates/astro-blog/starter/src/lib/seo-insights.ts +14 -0
- package/bundled-templates/astro-blog/starter/src/lib/seo.ts +33 -0
- package/bundled-templates/astro-blog/starter/src/lib/types.ts +58 -0
- package/bundled-templates/astro-blog/starter/src/lib/wordpress.ts +847 -0
- package/bundled-templates/astro-blog/starter/src/middleware.ts +7 -0
- package/bundled-templates/astro-blog/starter/src/pages/404.astro +11 -0
- package/bundled-templates/astro-blog/starter/src/pages/about.astro +15 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/approval/[token].ts +25 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/consent.ts +8 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/agency.ts +47 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/authors.ts +11 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/bulk.ts +58 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/calendar/publish-due.ts +12 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/config/validate.ts +4 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/content-tracking/report-state/batch.ts +19 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/content-tracking/report-state.ts +15 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/ctas.ts +5 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/entities.ts +23 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/media/optimize.ts +5 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/media/upload.ts +30 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/migrations/[id]/resume.ts +28 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/migrations/[id]/validate.ts +6 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/migrations/[id].ts +7 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/migrations/import.ts +458 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/migrations/media.ts +5 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/operations.ts +189 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/playbooks/[id]/run.ts +55 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/posts/[id]/approve.ts +13 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/posts/[id]/preview.ts +9 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/posts/[id]/publish.ts +13 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/posts/[id].ts +18 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/posts/index.ts +28 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/providers.ts +6 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/pull/history.ts +3 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/pull/latest.ts +3 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/pull/posts.ts +3 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/pull/project-context.ts +4 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/pull/sync-updates.ts +43 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/pull/sync.ts +172 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/pull/updates.ts +3 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/redirects/check.ts +5 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/redirects.ts +32 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/reports/deliver-due.ts +12 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/reports/deliver.ts +6 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/reports/export.ts +17 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/reports.ts +31 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/rewrite/history/[contentId].ts +3 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/rewrite/policy.ts +4 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/rewrite/queue.ts +4 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/seo/audit.ts +7 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/seo/insights.ts +5 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/settings/site.ts +7 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/settings.ts +19 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/sitemap/auto-detect.ts +13 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/sitemap/diff.ts +11 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/sitemap/match.ts +16 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/sitemap/matching-history.ts +4 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/sitemap/runs.ts +4 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/sitemap.ts +11 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/summary.ts +12 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/topics/[id]/faq.ts +12 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/topics.ts +11 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/wordpress/capabilities.ts +5 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/wordpress/migration-plan.ts +21 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/wordpress/migrations/[id]/resume.ts +5 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/wordpress/migrations/[id]/validate.ts +6 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/wordpress/migrations/[id].ts +13 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/wordpress/migrations.ts +147 -0
- package/bundled-templates/astro-blog/starter/src/pages/authors/[slug].astro +12 -0
- package/bundled-templates/astro-blog/starter/src/pages/blog/[slug].astro +25 -0
- package/bundled-templates/astro-blog/starter/src/pages/blog/index.astro +32 -0
- package/bundled-templates/astro-blog/starter/src/pages/blog/page/[page].astro +21 -0
- package/bundled-templates/astro-blog/starter/src/pages/ops/index.astro +20 -0
- package/bundled-templates/astro-blog/starter/src/pages/ops/operations.astro +33 -0
- package/bundled-templates/astro-blog/starter/src/pages/ops/posts/[id]/edit.astro +17 -0
- package/bundled-templates/astro-blog/starter/src/pages/ops/posts/[id]/preview.astro +11 -0
- package/bundled-templates/astro-blog/starter/src/pages/ops/posts/index.astro +15 -0
- package/bundled-templates/astro-blog/starter/src/pages/ops/posts/new.astro +12 -0
- package/bundled-templates/astro-blog/starter/src/pages/ops/reports.astro +12 -0
- package/bundled-templates/astro-blog/starter/src/pages/ops/rewrite.astro +13 -0
- package/bundled-templates/astro-blog/starter/src/pages/ops/settings/integrations.astro +13 -0
- package/bundled-templates/astro-blog/starter/src/pages/ops/settings/site.astro +11 -0
- package/bundled-templates/astro-blog/starter/src/pages/ops/sitemap.astro +15 -0
- package/bundled-templates/astro-blog/starter/src/pages/ops/topics.astro +13 -0
- package/bundled-templates/astro-blog/starter/src/pages/ops/wordpress.astro +34 -0
- package/bundled-templates/astro-blog/starter/src/pages/review/[token].astro +13 -0
- package/bundled-templates/astro-blog/starter/src/pages/robots.txt.ts +4 -0
- package/bundled-templates/astro-blog/starter/src/pages/sitemap.xml.ts +12 -0
- package/bundled-templates/astro-blog/starter/src/pages/topics/[slug]/page/[page].astro +10 -0
- package/bundled-templates/astro-blog/starter/src/pages/topics/[slug].astro +19 -0
- package/bundled-templates/astro-blog/starter/src/pages/topics/index.astro +10 -0
- package/bundled-templates/astro-blog/starter/src/styles/global.css +4 -0
- package/bundled-templates/astro-blog/starter/tsconfig.json +9 -0
- package/bundled-templates/astro-blog/starter/wrangler.jsonc +52 -0
- package/bundled-tools/clis/maggie_clone.py +42 -15
- package/bundled-tools/clis/maggie_dash.py +41 -25
- package/bundled-tools/clis/maggie_design.py +2 -0
- package/bundled-tools/clis/maggie_design_system.py +7 -1
- package/bundled-tools/clis/site_audit.py +25 -2
- package/bundled-tools/runtime/maggie_memory.py +6 -1
- package/package.json +1 -1
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { audit, db, id, now } from "../../../lib/db";
|
|
4
|
+
import { json } from "../../../lib/ops-auth";
|
|
5
|
+
|
|
6
|
+
const actor = "maggie-ops";
|
|
7
|
+
const bodyOf = async (request: Request) => await request.json().catch(() => null) as Record<string, any> | null;
|
|
8
|
+
const safeLimit = (value: unknown) => Math.min(Math.max(Number(value || 50), 1), 200);
|
|
9
|
+
const slugify = (value: string) => value.toLowerCase().normalize("NFKD").replace(/[^\w\s-]/g, "").trim().replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 180) || "untitled";
|
|
10
|
+
const redactSecrets = (value: unknown): unknown => Array.isArray(value) ? value.map(redactSecrets) : (value && typeof value === "object" ? Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([key, entry]) => [key, /(key|token|secret|password|credential)/i.test(key) ? "[redacted]" : redactSecrets(entry)])) : value);
|
|
11
|
+
|
|
12
|
+
export const GET: APIRoute = ({ url }) => {
|
|
13
|
+
const action = url.searchParams.get("action") || "calendar";
|
|
14
|
+
const limit = safeLimit(url.searchParams.get("limit"));
|
|
15
|
+
if (action === "calendar") { const clauses: string[] = []; const params: unknown[] = []; if (url.searchParams.get("campaign")) { clauses.push("c.campaign=?"); params.push(url.searchParams.get("campaign")); } if (url.searchParams.get("authorId")) { clauses.push("p.author_id=?"); params.push(url.searchParams.get("authorId")); } if (url.searchParams.get("topicId")) { clauses.push("EXISTS (SELECT 1 FROM post_topics pt WHERE pt.post_id=p.id AND pt.topic_id=?)"); params.push(url.searchParams.get("topicId")); } if (url.searchParams.get("overdue") === "true") { clauses.push("c.scheduled_at<? AND p.status!='published'"); params.push(now()); } return json({ items: db().prepare(`SELECT c.*,p.slug,p.title,p.status FROM content_calendar c JOIN posts p ON p.id=c.post_id ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""} ORDER BY c.scheduled_at LIMIT ?`).all(...params, limit) }); }
|
|
16
|
+
if (action === "revisions") { const postId = url.searchParams.get("postId"); if (!postId) return json({ error: "postId is required" }, 400); return json({ items: db().prepare("SELECT id,post_id,version,actor_id,reason,created_at FROM post_revisions WHERE post_id=? ORDER BY version DESC LIMIT ?").all(postId, limit) }); }
|
|
17
|
+
if (action === "revision-diff") { const revisionId = url.searchParams.get("revisionId"); if (!revisionId) return json({ error: "revisionId is required" }, 400); const revision = db().prepare("SELECT * FROM post_revisions WHERE id=?").get(revisionId) as any; if (!revision) return json({ error: "revision not found" }, 404); const current = db().prepare("SELECT * FROM posts WHERE id=?").get(revision.post_id) as any; const before = JSON.parse(revision.snapshot_json); const keys = ["title","slug","excerpt","content_html","canonical_url","seo_title","meta_description","focus_keyword","schema_type"]; return json({ postId: revision.post_id, revisionId, changes: Object.fromEntries(keys.filter((key) => before[key] !== current?.[key]).map((key) => [key, { before: before[key] ?? null, after: current?.[key] ?? null }])) }); }
|
|
18
|
+
if (action === "redirects") return json({ items: db().prepare("SELECT * FROM redirects ORDER BY created_at DESC LIMIT ?").all(limit) });
|
|
19
|
+
if (action === "tasks") return json({ items: db().prepare("SELECT * FROM tasks ORDER BY CASE status WHEN 'blocked' THEN 0 WHEN 'open' THEN 1 ELSE 2 END,due_at LIMIT ?").all(limit) });
|
|
20
|
+
if (action === "media") { const clauses = ["1=1"]; const params: unknown[] = []; const search = url.searchParams.get("search"); const mime = url.searchParams.get("mime"); const status = url.searchParams.get("optimizationStatus"); const folderId = url.searchParams.get("folderId"); if (search) { clauses.push("(m.source_url LIKE ? OR m.alt_text LIKE ? OR m.caption LIKE ?)"); params.push(`%${search}%`, `%${search}%`, `%${search}%`); } if (mime) { clauses.push("m.mime_type=?"); params.push(mime); } if (status) { clauses.push("m.optimization_status=?"); params.push(status); } if (folderId) { clauses.push("EXISTS (SELECT 1 FROM media_folder_items mfi WHERE mfi.media_id=m.id AND mfi.folder_id=?)"); params.push(folderId); } return json({ items: db().prepare(`SELECT m.*,COUNT(pm.post_id) usage_count,(SELECT COUNT(*) FROM media_assets duplicate WHERE duplicate.source_url=m.source_url) duplicate_count FROM media_assets m LEFT JOIN post_media pm ON pm.media_id=m.id WHERE ${clauses.join(" AND ")} GROUP BY m.id ORDER BY m.updated_at DESC LIMIT ?`).all(...params, limit) }); }
|
|
21
|
+
if (action === "media-folders") return json({ items: db().prepare("SELECT f.*,COUNT(mfi.media_id) media_count FROM media_folders f LEFT JOIN media_folder_items mfi ON mfi.folder_id=f.id GROUP BY f.id ORDER BY f.name").all() });
|
|
22
|
+
if (action === "workspaces") return json({ items: db().prepare("SELECT * FROM workspaces ORDER BY name LIMIT ?").all(limit) });
|
|
23
|
+
if (action === "activity") return json({ items: db().prepare("SELECT id,actor_id,action,entity_type,entity_id,reason,created_at FROM audit_events ORDER BY created_at DESC LIMIT ?").all(limit) });
|
|
24
|
+
if (action === "clients") return json({ items: db().prepare("SELECT c.*,w.name workspace_name FROM clients c JOIN workspaces w ON w.id=c.workspace_id ORDER BY c.name LIMIT ?").all(limit) });
|
|
25
|
+
if (action === "sites") return json({ items: (db().prepare("SELECT s.id,s.workspace_id,s.client_id,s.name,s.domain,s.settings_json,s.created_at,s.updated_at,c.name client_name,w.name workspace_name FROM sites s JOIN workspaces w ON w.id=s.workspace_id LEFT JOIN clients c ON c.id=s.client_id ORDER BY s.name LIMIT ?").all(limit) as any[]).map((site) => ({ ...site, settings_json: redactSecrets(JSON.parse(site.settings_json || "{}")) })) });
|
|
26
|
+
if (action === "projects") return json({ items: db().prepare("SELECT p.*,s.domain,w.name workspace_name FROM projects p JOIN workspaces w ON w.id=p.workspace_id LEFT JOIN sites s ON s.id=p.site_id ORDER BY p.name LIMIT ?").all(limit) });
|
|
27
|
+
if (action === "templates") return json({ items: db().prepare("SELECT * FROM workspace_templates ORDER BY name LIMIT ?").all(limit) });
|
|
28
|
+
if (action === "playbooks") return json({ items: db().prepare("SELECT * FROM playbooks ORDER BY name LIMIT ?").all(limit) });
|
|
29
|
+
if (action === "bulk") return json({ items: db().prepare("SELECT * FROM bulk_operations ORDER BY created_at DESC LIMIT ?").all(limit) });
|
|
30
|
+
if (action === "report-deliveries") return json({ items: db().prepare("SELECT * FROM report_deliveries ORDER BY created_at DESC LIMIT ?").all(limit) });
|
|
31
|
+
if (action === "rewrite") return json({ items: db().prepare("SELECT q.*,p.slug,p.title FROM rewrite_queue_items q JOIN posts p ON p.id=q.post_id ORDER BY q.priority DESC,q.created_at LIMIT ?").all(limit) });
|
|
32
|
+
if (action === "notifications") return json({ items: db().prepare("SELECT * FROM notifications WHERE read_at IS NULL ORDER BY created_at DESC LIMIT ?").all(limit) });
|
|
33
|
+
if (action === "fact-reminders") return json({ items: db().prepare("SELECT f.*,p.slug,p.title FROM fact_review_reminders f JOIN posts p ON p.id=f.post_id WHERE f.status=? ORDER BY f.due_at LIMIT ?").all(url.searchParams.get("status") || "pending", limit) });
|
|
34
|
+
if (action === "404") return json({ items: db().prepare("SELECT * FROM not_found_logs ORDER BY hits DESC,last_seen_at DESC LIMIT ?").all(limit) });
|
|
35
|
+
if (action === "citations") return json({ items: db().prepare("SELECT c.*,p.slug,p.title FROM content_citations c JOIN posts p ON p.id=c.post_id ORDER BY c.created_at DESC LIMIT ?").all(limit) });
|
|
36
|
+
if (action === "query-map") return json({ items: db().prepare("SELECT q.*,p.slug,p.title FROM query_page_map q LEFT JOIN posts p ON p.id=q.post_id ORDER BY q.priority DESC,q.query LIMIT ?").all(limit) });
|
|
37
|
+
if (action === "entities") return json({ items: db().prepare("SELECT * FROM entity_profiles ORDER BY entity_type,name LIMIT ?").all(limit) });
|
|
38
|
+
if (action === "mentions") return json({ items: db().prepare("SELECT * FROM brand_mentions ORDER BY checked_at DESC LIMIT ?").all(limit) });
|
|
39
|
+
if (action === "indexability") return json({ items: db().prepare("SELECT * FROM indexability_checks ORDER BY checked_at DESC LIMIT ?").all(limit) });
|
|
40
|
+
if (action === "vitals") return json({ items: db().prepare("SELECT * FROM core_web_vitals ORDER BY period_start DESC LIMIT ?").all(limit) });
|
|
41
|
+
return json({ error: "unknown action" }, 400);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const POST: APIRoute = async ({ request }) => {
|
|
45
|
+
const body = await bodyOf(request);
|
|
46
|
+
const action = body?.action;
|
|
47
|
+
if (!action) return json({ error: "action is required" }, 400);
|
|
48
|
+
const timestamp = now();
|
|
49
|
+
try {
|
|
50
|
+
if (action === "schedule") {
|
|
51
|
+
if (!body.postId || !body.scheduledAt) return json({ error: "postId and scheduledAt are required" }, 400); const schedulable = db().prepare("SELECT status FROM posts WHERE id=?").get(body.postId) as { status: string } | undefined; if (!schedulable) return json({ error: "post not found" }, 404); if (!["review", "approved", "scheduled"].includes(schedulable.status)) return json({ error: "only review, approved, or already scheduled posts can be scheduled" }, 409);
|
|
52
|
+
db().prepare("INSERT INTO content_calendar (id,post_id,scheduled_at,campaign,notes,reminder_at,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(post_id) DO UPDATE SET scheduled_at=excluded.scheduled_at,campaign=excluded.campaign,notes=excluded.notes,reminder_at=excluded.reminder_at,updated_at=excluded.updated_at").run(id("calendar"), body.postId, body.scheduledAt, body.campaign || null, body.notes || null, body.reminderAt || null, timestamp, timestamp);
|
|
53
|
+
db().prepare("UPDATE posts SET scheduled_at=?,status=CASE WHEN status='published' THEN status ELSE 'scheduled' END,updated_at=? WHERE id=?").run(body.scheduledAt, timestamp, body.postId);
|
|
54
|
+
audit("calendar.scheduled", "post", body.postId, null, body); return json({ ok: true, postId: body.postId });
|
|
55
|
+
}
|
|
56
|
+
if (action === "remind-overdue") {
|
|
57
|
+
const overdue = db().prepare("SELECT p.id,p.title FROM content_calendar c JOIN posts p ON p.id=c.post_id WHERE c.scheduled_at<? AND p.status!='published'").all(timestamp) as Array<{ id: string; title: string }>; const insert = db().prepare("INSERT INTO notifications (id,user_id,type,entity_type,entity_id,message,created_at) VALUES (?,?,?,?,?,?,?)"); const tx = db().transaction(() => overdue.forEach((post) => insert.run(id("notification"), body.userId || null, "overdue_content", "post", post.id, `Scheduled content is overdue: ${post.title}`, timestamp))); tx(); return json({ notified: overdue.length });
|
|
58
|
+
}
|
|
59
|
+
if (action === "fact-reminder") {
|
|
60
|
+
if (!body.postId || !body.dueAt || !body.reason) return json({ error: "postId, dueAt and reason are required" }, 400); if (!db().prepare("SELECT 1 FROM posts WHERE id=?").get(body.postId)) return json({ error: "post not found" }, 404); const reminderId = id("fact_review"); db().prepare("INSERT INTO fact_review_reminders (id,post_id,due_at,reason,status,created_at,updated_at) VALUES (?,?,?,?,?,?,?) ON CONFLICT(post_id,status) DO UPDATE SET due_at=excluded.due_at,reason=excluded.reason,updated_at=excluded.updated_at").run(reminderId, body.postId, body.dueAt, body.reason, "pending", timestamp, timestamp); audit("fact_review.created", "post", body.postId, null, body); return json({ id: reminderId, status: "pending" }, 201);
|
|
61
|
+
}
|
|
62
|
+
if (action === "fact-reminder-update") {
|
|
63
|
+
if (!body.reminderId || !["pending", "completed", "snoozed"].includes(body.status)) return json({ error: "reminderId and supported status are required" }, 400); db().prepare("UPDATE fact_review_reminders SET status=?,completed_at=CASE WHEN ?='completed' THEN ? ELSE completed_at END,due_at=COALESCE(?,due_at),updated_at=? WHERE id=?").run(body.status, body.status, timestamp, body.dueAt || null, timestamp, body.reminderId); return json({ ok: true, id: body.reminderId, status: body.status });
|
|
64
|
+
}
|
|
65
|
+
if (action === "revision") {
|
|
66
|
+
if (!body.postId) return json({ error: "postId is required" }, 400);
|
|
67
|
+
const post = db().prepare("SELECT * FROM posts WHERE id=?").get(body.postId) as Record<string, unknown> | undefined; if (!post) return json({ error: "post not found" }, 404);
|
|
68
|
+
const version = Number((db().prepare("SELECT MAX(version) version FROM post_revisions WHERE post_id=?").get(body.postId) as any)?.version || 0) + 1;
|
|
69
|
+
db().prepare("INSERT INTO post_revisions (id,post_id,version,snapshot_json,actor_id,reason,created_at) VALUES (?,?,?,?,?,?,?)").run(id("revision"), body.postId, version, JSON.stringify(post), body.actorId || actor, body.reason || "manual snapshot", timestamp);
|
|
70
|
+
return json({ ok: true, version });
|
|
71
|
+
}
|
|
72
|
+
if (action === "restore") {
|
|
73
|
+
const revision = db().prepare("SELECT * FROM post_revisions WHERE id=?").get(body.revisionId) as any; if (!revision) return json({ error: "revision not found" }, 404);
|
|
74
|
+
const snapshot = JSON.parse(revision.snapshot_json); const allowed = ["title","slug","excerpt","content_markdown","content_html","canonical_url","cover_image_url","cover_image_alt","language","seo_title","meta_description","focus_keyword","canonical_override","robots_index","robots_follow","og_image_url","schema_type","custom_metadata_json"];
|
|
75
|
+
const set = allowed.filter((key) => snapshot[key] !== undefined); if (!set.length) return json({ error: "revision has no restorable fields" }, 409);
|
|
76
|
+
const current = db().prepare("SELECT * FROM posts WHERE id=?").get(revision.post_id); const version = Number((db().prepare("SELECT MAX(version) version FROM post_revisions WHERE post_id=?").get(revision.post_id) as any)?.version || 0) + 1; db().prepare("INSERT INTO post_revisions (id,post_id,version,snapshot_json,actor_id,reason,created_at) VALUES (?,?,?,?,?,?,?)").run(id("revision"), revision.post_id, version, JSON.stringify(current), actor, "before restore", timestamp); db().prepare(`UPDATE posts SET ${set.map((key) => `${key}=?`).join(",")},updated_at=? WHERE id=?`).run(...set.map((key) => snapshot[key]), timestamp, revision.post_id);
|
|
77
|
+
audit("revision.restored", "post", revision.post_id, null, { revisionId: revision.id }); return json({ ok: true, postId: revision.post_id });
|
|
78
|
+
}
|
|
79
|
+
if (action === "bulk-preview" || action === "bulk-apply") {
|
|
80
|
+
const filter = body.filter && typeof body.filter === "object" ? body.filter : {}; const changes = body.changes && typeof body.changes === "object" ? body.changes : {};
|
|
81
|
+
const allowedFilters: Record<string, string> = { status: "status", source: "source", authorId: "author_id", language: "language" }; const clauses: string[] = []; const params: unknown[] = [];
|
|
82
|
+
for (const [key, column] of Object.entries(allowedFilters)) if (filter[key] !== undefined) { clauses.push(`p.${column}=?`); params.push(filter[key]); }
|
|
83
|
+
const rows = db().prepare(`SELECT p.id,p.slug,p.title,p.status FROM posts p ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""} ORDER BY p.updated_at DESC LIMIT 1000`).all(...params) as any[];
|
|
84
|
+
const operationId = id("bulk"); db().prepare("INSERT INTO bulk_operations (id,operation,filter_json,changes_json,preview_json,status,actor_id,created_at) VALUES (?,?,?,?,?,?,?,?)").run(operationId, body.operation || "update", JSON.stringify(filter), JSON.stringify(changes), JSON.stringify({ count: rows.length, ids: rows.map((row) => row.id) }), action === "bulk-preview" ? "preview" : "running", body.actorId || actor, timestamp);
|
|
85
|
+
if (action === "bulk-preview") return json({ id: operationId, preview: { count: rows.length, posts: rows } });
|
|
86
|
+
const keys = Object.keys(changes).filter((key) => /^[a-z_]+$/.test(key) && ["status","seo_title","meta_description","focus_keyword","canonical_override","robots_index","robots_follow","og_image_url","schema_type","custom_metadata_json","language","author_id","cover_image_alt"].includes(key));
|
|
87
|
+
if (!keys.length && !changes.regenerateSlug && !changes.regenerateDescription) return json({ error: "no supported changes" }, 400);
|
|
88
|
+
if (keys.length) { const update = db().prepare(`UPDATE posts SET ${keys.map((key) => `${key}=?`).join(",")},updated_at=? WHERE id=?`); const snapshot = db().prepare("SELECT * FROM posts WHERE id=?"); const revision = db().prepare("INSERT INTO post_revisions (id,post_id,version,snapshot_json,actor_id,reason,created_at) VALUES (?,?,?,?,?,?,?)"); const tx = db().transaction(() => rows.forEach((row) => { const current = snapshot.get(row.id); const version = Number((db().prepare("SELECT MAX(version) version FROM post_revisions WHERE post_id=?").get(row.id) as any)?.version || 0) + 1; revision.run(id("revision"), row.id, version, JSON.stringify(current), body.actorId || actor, "bulk operation", timestamp); update.run(...keys.map((key) => changes[key]), timestamp, row.id); })); tx(); }
|
|
89
|
+
if (changes.regenerateSlug || changes.regenerateDescription) { const normalize = db().prepare("SELECT id,title,content_html FROM posts WHERE id=?"); const updateNormalized = db().prepare("UPDATE posts SET slug=COALESCE(?,slug),excerpt=COALESCE(?,excerpt),updated_at=? WHERE id=?"); for (const row of rows) { const current = normalize.get(row.id) as any; const nextSlug = changes.regenerateSlug ? slugify(current.title) : null; const description = changes.regenerateDescription ? String(current.content_html || "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim().slice(0, 155) : null; const owner = nextSlug ? db().prepare("SELECT id FROM posts WHERE slug=? AND id!=?").get(nextSlug, row.id) : null; updateNormalized.run(owner ? null : nextSlug, description, timestamp, row.id); } }
|
|
90
|
+
if (changes.queueRewrite) { const queue = db().prepare("INSERT INTO rewrite_queue_items (id,post_id,reason,status,priority,requested_by,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(post_id,status) DO UPDATE SET reason=excluded.reason,priority=excluded.priority,updated_at=excluded.updated_at"); for (const row of rows) queue.run(id("rewrite"), row.id, changes.rewriteReason || "bulk operation", "queued", Number(changes.rewritePriority || 0), body.actorId || actor, timestamp, timestamp); }
|
|
91
|
+
db().prepare("UPDATE bulk_operations SET status='completed',completed_at=? WHERE id=?").run(now(), operationId); audit("bulk.applied", "bulk_operation", operationId, null, { count: rows.length, changes }); return json({ id: operationId, applied: rows.length });
|
|
92
|
+
}
|
|
93
|
+
if (action === "bulk-taxonomy") {
|
|
94
|
+
if (!Array.isArray(body.postIds) || !body.postIds.length) return json({ error: "postIds is required" }, 400); const postIds = body.postIds.map(String); const tx = db().transaction(() => { for (const postId of postIds) { const current = db().prepare("SELECT * FROM posts WHERE id=?").get(postId); const version = Number((db().prepare("SELECT MAX(version) version FROM post_revisions WHERE post_id=?").get(postId) as any)?.version || 0) + 1; db().prepare("INSERT INTO post_revisions (id,post_id,version,snapshot_json,actor_id,reason,created_at) VALUES (?,?,?,?,?,?,?)").run(id("revision"), postId, version, JSON.stringify(current), body.actorId || actor, "bulk taxonomy operation", timestamp); if (body.authorId) db().prepare("UPDATE posts SET author_id=?,updated_at=? WHERE id=?").run(body.authorId, timestamp, postId); if (Array.isArray(body.topicIds)) { db().prepare("DELETE FROM post_topics WHERE post_id=?").run(postId); for (const topicId of body.topicIds) db().prepare("INSERT OR IGNORE INTO post_topics (post_id,topic_id) VALUES (?,?)").run(postId, topicId); } if (Array.isArray(body.termIds)) { db().prepare("DELETE FROM post_wp_terms WHERE post_id=?").run(postId); for (const termId of body.termIds) db().prepare("INSERT OR IGNORE INTO post_wp_terms (post_id,term_id) SELECT ?,id FROM wp_terms WHERE id=?").run(postId, termId); } } }); tx(); audit("bulk.taxonomy_applied", "post", postIds[0], null, { count: postIds.length, topics: body.topicIds || [], terms: body.termIds || [], authorId: body.authorId || null }); return json({ applied: postIds.length });
|
|
95
|
+
}
|
|
96
|
+
if (action === "queue-rewrite") {
|
|
97
|
+
if (!body.postId) return json({ error: "postId is required" }, 400); const queueId = id("rewrite"); db().prepare("INSERT INTO rewrite_queue_items (id,post_id,reason,status,priority,requested_by,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(post_id,status) DO UPDATE SET priority=excluded.priority,reason=excluded.reason,updated_at=excluded.updated_at").run(queueId, body.postId, body.reason || "manual", "queued", Number(body.priority || 0), body.requestedBy || actor, timestamp, timestamp); audit("rewrite.queued", "post", body.postId, null, body); return json({ ok: true, postId: body.postId });
|
|
98
|
+
}
|
|
99
|
+
if (action === "notification") {
|
|
100
|
+
if (!body.message || !body.type) return json({ error: "type and message are required" }, 400); const notificationId = id("notification"); db().prepare("INSERT INTO notifications (id,user_id,type,entity_type,entity_id,message,created_at) VALUES (?,?,?,?,?,?,?)").run(notificationId, body.userId || null, body.type, body.entityType || null, body.entityId || null, body.message, timestamp); return json({ id: notificationId }, 201);
|
|
101
|
+
}
|
|
102
|
+
if (action === "report-delivery") {
|
|
103
|
+
if (!body.reportType || !body.recipient) return json({ error: "reportType and recipient are required" }, 400); const formats = ["json", "csv", "html", "pdf"]; if (!formats.includes(body.format || "json")) return json({ error: "unsupported report format" }, 400); const schedule = body.schedule == null || body.schedule === "daily" || body.schedule === "weekly" || body.schedule === "monthly" || /^\d{4}-\d{2}-\d{2}T/.test(String(body.schedule)); if (!schedule) return json({ error: "schedule must be daily, weekly, monthly, or an ISO timestamp" }, 400); const deliveryId = id("delivery"); db().prepare("INSERT INTO report_deliveries (id,workspace_id,report_type,recipient,format,schedule,created_at) VALUES (?,?,?,?,?,?,?)").run(deliveryId, body.workspaceId || null, body.reportType, body.recipient, body.format || "json", body.schedule || null, timestamp); return json({ id: deliveryId, status: "active", schedule: body.schedule || null }, 201);
|
|
104
|
+
}
|
|
105
|
+
if (action === "not-found") {
|
|
106
|
+
if (!body.path || !String(body.path).startsWith("/")) return json({ error: "path must start with /" }, 400); db().prepare("INSERT INTO not_found_logs (path,hits,referrer,first_seen_at,last_seen_at) VALUES (?,?,?,?,?) ON CONFLICT(path) DO UPDATE SET hits=hits+1,referrer=excluded.referrer,last_seen_at=excluded.last_seen_at").run(body.path, 1, body.referrer || null, timestamp, timestamp); return json({ ok: true });
|
|
107
|
+
}
|
|
108
|
+
if (action === "citation") {
|
|
109
|
+
if (!body.postId || !body.sourceUrl) return json({ error: "postId and sourceUrl are required" }, 400); const citationId = id("citation"); db().prepare("INSERT INTO content_citations (id,post_id,source_url,source_title,verified_at,created_at) VALUES (?,?,?,?,?,?)").run(citationId, body.postId, body.sourceUrl, body.sourceTitle || null, body.verified ? timestamp : null, timestamp); return json({ id: citationId }, 201);
|
|
110
|
+
}
|
|
111
|
+
if (action === "media-optimize") {
|
|
112
|
+
if (!body.mediaId) return json({ error: "mediaId is required" }, 400); const formats = (Array.isArray(body.formats) ? body.formats : ["webp", "avif"]).filter((value: unknown) => value === "webp" || value === "avif"); if (!formats.length) return json({ error: "formats must include webp or avif" }, 400); db().prepare("UPDATE media_assets SET optimized_formats_json=?,optimization_status='queued',updated_at=? WHERE id=?").run(JSON.stringify(formats), timestamp, body.mediaId); return json({ ok: true, mediaId: body.mediaId, formats, status: "queued" });
|
|
113
|
+
}
|
|
114
|
+
if (action === "media-update") {
|
|
115
|
+
if (!body.mediaId) return json({ error: "mediaId is required" }, 400); const media = db().prepare("SELECT id FROM media_assets WHERE id=?").get(body.mediaId); if (!media) return json({ error: "media not found" }, 404); const fields: string[] = []; const values: unknown[] = []; for (const key of ["alt_text", "caption"]) if (body[key] !== undefined) { fields.push(`${key}=?`); values.push(String(body[key])); } if (!fields.length) return json({ error: "alt_text or caption is required" }, 400); fields.push("updated_at=?"); values.push(timestamp, body.mediaId); db().prepare(`UPDATE media_assets SET ${fields.join(",")} WHERE id=?`).run(...values); audit("media.updated", "media", body.mediaId, null, body); return json({ ok: true, mediaId: body.mediaId });
|
|
116
|
+
}
|
|
117
|
+
if (action === "media-folder") {
|
|
118
|
+
if (!body.name) return json({ error: "name is required" }, 400); const folderId = body.folderId || id("media_folder"); db().prepare("INSERT INTO media_folders (id,name,created_at) VALUES (?,?,?) ON CONFLICT(name) DO NOTHING").run(folderId, String(body.name), timestamp); return json({ id: folderId, name: String(body.name) }, 201);
|
|
119
|
+
}
|
|
120
|
+
if (action === "media-folder-item") {
|
|
121
|
+
if (!body.mediaId || !body.folderId) return json({ error: "mediaId and folderId are required" }, 400); db().prepare("INSERT OR IGNORE INTO media_folder_items (media_id,folder_id) VALUES (?,?)").run(body.mediaId, body.folderId); return json({ ok: true });
|
|
122
|
+
}
|
|
123
|
+
if (action === "media-retry") {
|
|
124
|
+
if (!body.mediaId) return json({ error: "mediaId is required" }, 400); db().prepare("UPDATE media_assets SET optimization_status='pending',updated_at=? WHERE id=?").run(timestamp, body.mediaId); audit("media.retry", "media", body.mediaId, null, { status: "pending" }); return json({ ok: true });
|
|
125
|
+
}
|
|
126
|
+
if (action === "query-map") {
|
|
127
|
+
if (!body.query) return json({ error: "query is required" }, 400); const mapId = id("query"); db().prepare("INSERT INTO query_page_map (id,query,post_id,intent,priority,created_at) VALUES (?,?,?,?,?,?) ON CONFLICT(query,post_id) DO UPDATE SET intent=excluded.intent,priority=excluded.priority").run(mapId, body.query, body.postId || null, body.intent || null, Number(body.priority || 0), timestamp); return json({ id: mapId }, 201);
|
|
128
|
+
}
|
|
129
|
+
if (action === "entity") {
|
|
130
|
+
if (!body.entityType || !body.entityKey || !body.name) return json({ error: "entityType, entityKey and name are required" }, 400); const entityId = id("entity"); db().prepare("INSERT INTO entity_profiles (id,entity_type,entity_key,name,same_as_json,schema_json,updated_at) VALUES (?,?,?,?,?,?,?) ON CONFLICT(entity_key) DO UPDATE SET name=excluded.name,same_as_json=excluded.same_as_json,schema_json=excluded.schema_json,updated_at=excluded.updated_at").run(entityId, body.entityType, body.entityKey, body.name, JSON.stringify(body.sameAs || []), JSON.stringify(body.schema || {}), timestamp); return json({ id: entityId }, 201);
|
|
131
|
+
}
|
|
132
|
+
if (action === "brand-mention") {
|
|
133
|
+
if (!body.query || !body.sourceUrl) return json({ error: "query and sourceUrl are required" }, 400); const mentionId = id("mention"); db().prepare("INSERT INTO brand_mentions (id,query,source_url,source_name,mentioned,checked_at) VALUES (?,?,?,?,?,?)").run(mentionId, body.query, body.sourceUrl, body.sourceName || null, body.mentioned === true ? 1 : 0, timestamp); return json({ id: mentionId }, 201);
|
|
134
|
+
}
|
|
135
|
+
if (action === "indexability") {
|
|
136
|
+
if (!body.url || !body.status) return json({ error: "url and status are required" }, 400); const checkId = id("indexability"); db().prepare("INSERT INTO indexability_checks (id,url,status,robots,canonical,checked_at,details_json) VALUES (?,?,?,?,?,?,?)").run(checkId, body.url, body.status, body.robots || null, body.canonical || null, timestamp, JSON.stringify(body.details || {})); return json({ id: checkId }, 201);
|
|
137
|
+
}
|
|
138
|
+
if (action === "vitals") {
|
|
139
|
+
if (!body.url || !body.periodStart) return json({ error: "url and periodStart are required" }, 400); const vitalsId = id("vitals"); db().prepare("INSERT INTO core_web_vitals (id,url,period_start,lcp,inp,cls,source,created_at) VALUES (?,?,?,?,?,?,?,?)").run(vitalsId, body.url, body.periodStart, body.lcp ?? null, body.inp ?? null, body.cls ?? null, body.source || "manual", timestamp); return json({ id: vitalsId }, 201);
|
|
140
|
+
}
|
|
141
|
+
if (action === "approval-comment") {
|
|
142
|
+
if (!body.approvalLinkId || !body.body) return json({ error: "approvalLinkId and body are required" }, 400); const commentId = id("comment"); db().prepare("INSERT INTO approval_comments (id,approval_link_id,paragraph_key,body,author_name,created_at) VALUES (?,?,?,?,?,?)").run(commentId, body.approvalLinkId, body.paragraphKey || null, body.body, body.authorName || null, timestamp); db().prepare("INSERT INTO notifications (id,user_id,type,entity_type,entity_id,message,created_at) VALUES (?,?,?,?,?,?,?)").run(id("notification"), null, "approval_comment", "approval_link", body.approvalLinkId, `New review comment${body.paragraphKey ? ` on ${body.paragraphKey}` : ""}`, timestamp); return json({ id: commentId }, 201);
|
|
143
|
+
}
|
|
144
|
+
if (action === "redirect") {
|
|
145
|
+
if (!body.sourcePath || !body.targetPath) return json({ error: "sourcePath and targetPath are required" }, 400);
|
|
146
|
+
const collision = db().prepare("SELECT source_path,target_path FROM redirects WHERE source_path=? AND target_path!=?").get(body.sourcePath, body.targetPath); const chain = db().prepare("SELECT source_path FROM redirects WHERE source_path=?").get(body.targetPath); db().prepare("INSERT INTO redirects (id,source_path,target_path,status_code,reason,created_at) VALUES (?,?,?,?,?,?) ON CONFLICT(source_path) DO UPDATE SET target_path=excluded.target_path,status_code=excluded.status_code,reason=excluded.reason").run(id("redirect"), body.sourcePath, body.targetPath, body.statusCode === 308 ? 308 : 301, body.reason || "manual", timestamp); return json({ ok: true, warnings: [collision ? "source path already mapped to a different target" : null, chain ? "target is itself redirected" : null].filter(Boolean) });
|
|
147
|
+
}
|
|
148
|
+
if (action === "task") {
|
|
149
|
+
if (!body.title) return json({ error: "title is required" }, 400); const taskId = id("task"); db().prepare("INSERT INTO tasks (id,title,description,status,assignee_id,due_at,linked_type,linked_id,approval_status,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)").run(taskId, body.title, body.description || "", body.status || "open", body.assigneeId || null, body.dueAt || null, body.linkedType || null, body.linkedId || null, body.approvalStatus || null, timestamp, timestamp); return json({ id: taskId }, 201);
|
|
150
|
+
}
|
|
151
|
+
if (action === "task-update") {
|
|
152
|
+
if (!body.taskId || !["open", "in_progress", "blocked", "done", "cancelled"].includes(body.status)) return json({ error: "taskId and supported status are required" }, 400); db().prepare("UPDATE tasks SET status=?,assignee_id=COALESCE(?,assignee_id),due_at=COALESCE(?,due_at),updated_at=? WHERE id=?").run(body.status, body.assigneeId || null, body.dueAt || null, timestamp, body.taskId); return json({ ok: true });
|
|
153
|
+
}
|
|
154
|
+
if (action === "task-comment") {
|
|
155
|
+
if (!body.taskId || !body.body || !body.authorId) return json({ error: "taskId, body and authorId are required" }, 400); const commentId = id("task_comment"); db().prepare("INSERT INTO task_comments (id,task_id,author_id,body,created_at) VALUES (?,?,?,?,?)").run(commentId, body.taskId, body.authorId, body.body, timestamp); return json({ id: commentId }, 201);
|
|
156
|
+
}
|
|
157
|
+
if (action === "notification-read") {
|
|
158
|
+
if (!body.notificationId) return json({ error: "notificationId is required" }, 400); db().prepare("UPDATE notifications SET read_at=? WHERE id=?").run(timestamp, body.notificationId); return json({ ok: true });
|
|
159
|
+
}
|
|
160
|
+
if (action === "workspace") {
|
|
161
|
+
if (!body.name || !body.slug) return json({ error: "name and slug are required" }, 400); const workspaceId = id("workspace"); db().prepare("INSERT INTO workspaces (id,name,slug,logo_url,brand_colors_json,custom_domain,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)").run(workspaceId, body.name, body.slug, body.logoUrl || null, JSON.stringify(body.brandColors || {}), body.customDomain || null, timestamp, timestamp); return json({ id: workspaceId }, 201);
|
|
162
|
+
}
|
|
163
|
+
if (action === "client") {
|
|
164
|
+
if (!body.workspaceId || !body.name) return json({ error: "workspaceId and name are required" }, 400); const clientId = id("client"); db().prepare("INSERT INTO clients (id,workspace_id,name,email,created_at,updated_at) VALUES (?,?,?,?,?,?)").run(clientId, body.workspaceId, body.name, body.email || null, timestamp, timestamp); return json({ id: clientId }, 201);
|
|
165
|
+
}
|
|
166
|
+
if (action === "site") {
|
|
167
|
+
if (!body.workspaceId || !body.name || !body.domain) return json({ error: "workspaceId, name and domain are required" }, 400); if (body.credentials) return json({ error: "Credentials must be configured through a server-side provider reference" }, 400); const siteId = id("site"); db().prepare("INSERT INTO sites (id,workspace_id,client_id,name,domain,settings_json,credentials_json,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?)").run(siteId, body.workspaceId, body.clientId || null, body.name, body.domain, JSON.stringify(redactSecrets(body.settings || {})), JSON.stringify({ provider: body.credentialsProvider || null, reference: body.credentialsRef || null }), timestamp, timestamp); return json({ id: siteId }, 201);
|
|
168
|
+
}
|
|
169
|
+
if (action === "project") {
|
|
170
|
+
if (!body.workspaceId || !body.name) return json({ error: "workspaceId and name are required" }, 400); const projectId = id("project"); db().prepare("INSERT INTO projects (id,workspace_id,site_id,name,status,created_at,updated_at) VALUES (?,?,?,?,?,?,?)").run(projectId, body.workspaceId, body.siteId || null, body.name, body.status || "active", timestamp, timestamp); return json({ id: projectId }, 201);
|
|
171
|
+
}
|
|
172
|
+
if (action === "template") {
|
|
173
|
+
if (!body.workspaceId || !body.name || !body.templateType) return json({ error: "workspaceId, name and templateType are required" }, 400); const templateId = id("template"); db().prepare("INSERT INTO workspace_templates (id,workspace_id,name,template_type,content_json,created_at,updated_at) VALUES (?,?,?,?,?,?,?) ON CONFLICT(workspace_id,name) DO UPDATE SET template_type=excluded.template_type,content_json=excluded.content_json,updated_at=excluded.updated_at").run(templateId, body.workspaceId, body.name, body.templateType, JSON.stringify(body.content || {}), timestamp, timestamp); return json({ id: templateId }, 201);
|
|
174
|
+
}
|
|
175
|
+
if (action === "playbook") {
|
|
176
|
+
if (!body.name || !Array.isArray(body.steps)) return json({ error: "name and steps are required" }, 400); const playbookId = id("playbook"); db().prepare("INSERT INTO playbooks (id,name,description,steps_json,gates_json,report_format,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)").run(playbookId, body.name, body.description || "", JSON.stringify(body.steps), JSON.stringify(body.gates || []), body.reportFormat || "json", timestamp, timestamp); return json({ id: playbookId }, 201);
|
|
177
|
+
}
|
|
178
|
+
if (action === "playbook-run") {
|
|
179
|
+
if (!body.playbookId) return json({ error: "playbookId is required" }, 400); const runId = id("playbook_run"); db().prepare("INSERT INTO playbook_runs (id,playbook_id,site_id,status,state_json,started_at) VALUES (?,?,?,?,?,?)").run(runId, body.playbookId, body.siteId || null, "running", JSON.stringify({ currentStep: 0, confirmations: body.confirmations || {} }), timestamp); return json({ id: runId, status: "running" }, 201);
|
|
180
|
+
}
|
|
181
|
+
if (action === "member") {
|
|
182
|
+
const roles = ["agency-owner","account-manager","seo-strategist","writer","editor","developer","client-reviewer","read-only-analyst"]; if (!body.workspaceId || !body.userId || !roles.includes(body.role)) return json({ error: "workspaceId, userId and a supported role are required" }, 400); db().prepare("INSERT INTO workspace_members (workspace_id,user_id,role,created_at) VALUES (?,?,?,?) ON CONFLICT(workspace_id,user_id) DO UPDATE SET role=excluded.role").run(body.workspaceId, body.userId, body.role, timestamp); return json({ ok: true });
|
|
183
|
+
}
|
|
184
|
+
if (action === "approval-link") {
|
|
185
|
+
if (!body.postId) return json({ error: "postId is required" }, 400); const rawToken = `${crypto.randomUUID()}${crypto.randomUUID()}`; const tokenHash = createHash("sha256").update(rawToken).digest("hex"); const linkId = id("approval"); const expiresAt = body.expiresAt || new Date(Date.now() + 7 * 86400000).toISOString(); db().prepare("INSERT INTO approval_links (id,post_id,token_hash,expires_at,reviewer_name,reviewer_email,created_at) VALUES (?,?,?,?,?,?,?)").run(linkId, body.postId, tokenHash, expiresAt, body.reviewerName || null, body.reviewerEmail || null, timestamp); return json({ id: linkId, token: rawToken, expiresAt }, 201);
|
|
186
|
+
}
|
|
187
|
+
return json({ error: "unknown action" }, 400);
|
|
188
|
+
} catch (error) { return json({ error: error instanceof Error ? error.message : "operation failed" }, 409); }
|
|
189
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { audit, db, id, now } from "../../../../../lib/db";
|
|
3
|
+
import { json } from "../../../../../lib/ops-auth";
|
|
4
|
+
|
|
5
|
+
const parse = (value: unknown, fallback: unknown) => { try { return JSON.parse(String(value || "")); } catch { return fallback; } };
|
|
6
|
+
const getPlaybook = (playbookId: string) => db().prepare("SELECT * FROM playbooks WHERE id=?").get(playbookId) as { id: string; steps_json: string; gates_json: string; report_format: string } | undefined;
|
|
7
|
+
|
|
8
|
+
export const GET: APIRoute = ({ params, url }) => {
|
|
9
|
+
const run = db().prepare("SELECT * FROM playbook_runs WHERE id=? AND playbook_id=?").get(url.searchParams.get("runId"), params.id) as Record<string, unknown> | undefined;
|
|
10
|
+
if (!run) return json({ error: "playbook run not found" }, 404);
|
|
11
|
+
return json({ ...run, state: parse(run.state_json, {}) });
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const POST: APIRoute = async ({ params, request }) => {
|
|
15
|
+
const playbook = getPlaybook(params.id || "");
|
|
16
|
+
if (!playbook) return json({ error: "playbook not found" }, 404);
|
|
17
|
+
const body = await request.json().catch(() => null) as Record<string, unknown> | null;
|
|
18
|
+
const action = String(body?.action || "start");
|
|
19
|
+
const steps = parse(playbook.steps_json, []) as unknown[];
|
|
20
|
+
const gates = parse(playbook.gates_json, []) as unknown[];
|
|
21
|
+
if (!Array.isArray(steps) || !Array.isArray(gates)) return json({ error: "playbook definition is invalid" }, 422);
|
|
22
|
+
if (action === "start") {
|
|
23
|
+
const timestamp = now(); const runId = id("playbook_run");
|
|
24
|
+
const confirmations = body?.confirmations && typeof body.confirmations === "object" ? body.confirmations : {};
|
|
25
|
+
db().prepare("INSERT INTO playbook_runs (id,playbook_id,site_id,status,state_json,started_at) VALUES (?,?,?,?,?,?)").run(runId, playbook.id, body?.siteId || null, "running", JSON.stringify({ currentStep: 0, completedSteps: [], confirmations, gates, outputs: [] }), timestamp);
|
|
26
|
+
audit("playbook.started", "playbook_run", runId, null, { playbookId: playbook.id, siteId: body?.siteId || null });
|
|
27
|
+
return json({ id: runId, status: "running", currentStep: 0, totalSteps: steps.length }, 201);
|
|
28
|
+
}
|
|
29
|
+
const runId = String(body?.runId || "");
|
|
30
|
+
const run = db().prepare("SELECT * FROM playbook_runs WHERE id=? AND playbook_id=?").get(runId, playbook.id) as Record<string, unknown> | undefined;
|
|
31
|
+
if (!run) return json({ error: "playbook run not found" }, 404);
|
|
32
|
+
if (run.status !== "running") return json({ error: `playbook run is ${run.status}` }, 409);
|
|
33
|
+
const state = parse(run.state_json, {}) as { currentStep?: number; completedSteps?: number[]; confirmations?: Record<string, unknown>; gates?: unknown[]; outputs?: unknown[] };
|
|
34
|
+
const currentStep = Number(state.currentStep || 0);
|
|
35
|
+
if (action === "step") {
|
|
36
|
+
if (currentStep >= steps.length) return json({ error: "all playbook steps are complete" }, 409);
|
|
37
|
+
const step = steps[currentStep] && typeof steps[currentStep] === "object" ? steps[currentStep] as Record<string, unknown> : {};
|
|
38
|
+
const confirmationKey = String(step.confirmation || step.requiresConfirmation || "");
|
|
39
|
+
if (confirmationKey && state.confirmations?.[confirmationKey] !== true && body?.confirmed !== true) return json({ error: `confirmation required: ${confirmationKey}`, currentStep, step }, 409);
|
|
40
|
+
const nextState = { ...state, currentStep: currentStep + 1, completedSteps: [...(state.completedSteps || []), currentStep], outputs: [...(state.outputs || []), body?.output || null] };
|
|
41
|
+
db().prepare("UPDATE playbook_runs SET state_json=? WHERE id=?").run(JSON.stringify(nextState), runId);
|
|
42
|
+
audit("playbook.step_completed", "playbook_run", runId, null, { step: currentStep, name: step.name || null });
|
|
43
|
+
return json({ id: runId, status: "running", currentStep: nextState.currentStep, completedSteps: nextState.completedSteps, step });
|
|
44
|
+
}
|
|
45
|
+
if (action === "complete") {
|
|
46
|
+
if (currentStep < steps.length) return json({ error: "complete all playbook steps first", currentStep, totalSteps: steps.length }, 409);
|
|
47
|
+
const requiredGates = gates.filter((gate) => typeof gate === "string" ? true : Boolean((gate as Record<string, unknown>).required));
|
|
48
|
+
const passedGates = body?.passedGates && Array.isArray(body.passedGates) ? body.passedGates : [];
|
|
49
|
+
if (requiredGates.length && passedGates.length < requiredGates.length) return json({ error: "validation gates have not passed", required: requiredGates.length, passed: passedGates.length }, 409);
|
|
50
|
+
db().prepare("UPDATE playbook_runs SET status='completed',state_json=?,completed_at=?,error=NULL WHERE id=?").run(JSON.stringify({ ...state, gatesPassed: passedGates }), now(), runId);
|
|
51
|
+
audit("playbook.completed", "playbook_run", runId, null, { gates: passedGates, reportFormat: playbook.report_format });
|
|
52
|
+
return json({ id: runId, status: "completed", reportFormat: playbook.report_format });
|
|
53
|
+
}
|
|
54
|
+
return json({ error: "action must be start, step, or complete" }, 400);
|
|
55
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { audit, db, now } from "../../../../../lib/db";
|
|
3
|
+
import { json } from "../../../../../lib/ops-auth";
|
|
4
|
+
|
|
5
|
+
export const POST: APIRoute = ({ params }) => {
|
|
6
|
+
const post = db().prepare("SELECT * FROM posts WHERE id=?").get(params.id) as any;
|
|
7
|
+
if (!post) return json({ error: "Not found" }, 404);
|
|
8
|
+
if (post.status !== "review") return json({ error: "Only review posts can be approved" }, 409);
|
|
9
|
+
if (!post.title || !post.slug || !post.excerpt || !post.canonical_url || !post.author_id || !post.content_html) return json({ error: "Post is missing required content or SEO fields" }, 422);
|
|
10
|
+
db().prepare("UPDATE posts SET status='approved', updated_at=? WHERE id=?").run(now(), params.id);
|
|
11
|
+
audit("post.approved", "post", params.id!, post, { ...post, status: "approved" });
|
|
12
|
+
return json({ ok: true, id: params.id });
|
|
13
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { db } from "../../../../../lib/db";
|
|
3
|
+
import { json } from "../../../../../lib/ops-auth";
|
|
4
|
+
|
|
5
|
+
export const POST: APIRoute = ({ params }) => {
|
|
6
|
+
const post = db().prepare("SELECT id,slug,title,excerpt,content_html,canonical_url,status,updated_at FROM posts WHERE id=?").get(params.id);
|
|
7
|
+
if (!post) return json({ error: "Not found" }, 404);
|
|
8
|
+
return json({ noindex: true, preview: post });
|
|
9
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { audit, db, now } from "../../../../../lib/db";
|
|
3
|
+
import { json } from "../../../../../lib/ops-auth";
|
|
4
|
+
|
|
5
|
+
export const POST: APIRoute = ({ params }) => {
|
|
6
|
+
const post = db().prepare("SELECT * FROM posts WHERE id=?").get(params.id) as any;
|
|
7
|
+
if (!post) return json({ error: "Not found" }, 404);
|
|
8
|
+
if (post.status !== "approved" && post.status !== "scheduled") return json({ error: "Only approved or scheduled posts can be published" }, 409);
|
|
9
|
+
if (!post.title || !post.slug || !post.canonical_url || !post.author_id) return json({ error: "Post is missing publishable SEO or authorship fields" }, 422);
|
|
10
|
+
db().prepare("UPDATE posts SET status='published',published_at=COALESCE(published_at,?),updated_at=? WHERE id=?").run(now(), now(), params.id);
|
|
11
|
+
audit("post.published", "post", params.id!, post, { ...post, status: "published" });
|
|
12
|
+
return json({ ok: true, id: params.id });
|
|
13
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { audit, db, id, now } from "../../../../lib/db";
|
|
3
|
+
import { json } from "../../../../lib/ops-auth";
|
|
4
|
+
|
|
5
|
+
export const GET: APIRoute = ({ params }) => json(db().prepare("SELECT * FROM posts WHERE id=?").get(params.id) || { error: "Not found" }, db().prepare("SELECT 1 FROM posts WHERE id=?").get(params.id) ? 200 : 404);
|
|
6
|
+
export const PATCH: APIRoute = async ({ params, request }) => {
|
|
7
|
+
const existing = db().prepare("SELECT * FROM posts WHERE id=?").get(params.id) as any;
|
|
8
|
+
if (!existing) return json({ error: "Not found" }, 404);
|
|
9
|
+
const body = await request.json().catch(() => null) as any;
|
|
10
|
+
const allowed = ["title", "slug", "excerpt", "content_markdown", "content_html", "canonical_url", "cover_image_url", "cover_image_alt", "language", "status", "published_at", "seo_title", "meta_description", "focus_keyword", "canonical_override", "robots_index", "robots_follow", "og_image_url", "schema_type", "custom_metadata_json"];
|
|
11
|
+
const changes = Object.entries({ title: body?.title, slug: body?.slug, excerpt: body?.excerpt, content_markdown: body?.contentMarkdown, content_html: body?.contentHtml, canonical_url: body?.canonicalUrl, cover_image_url: body?.coverImageUrl, cover_image_alt: body?.coverImageAlt, language: body?.language, status: body?.status, published_at: body?.publishedAt, seo_title: body?.seoTitle, meta_description: body?.metaDescription, focus_keyword: body?.focusKeyword, canonical_override: body?.canonicalOverride, robots_index: body?.robotsIndex, robots_follow: body?.robotsFollow, og_image_url: body?.ogImageUrl, schema_type: body?.schemaType, custom_metadata_json: body?.customMetadataJson }).filter(([key, value]) => allowed.includes(key) && value !== undefined) as [string, unknown][];
|
|
12
|
+
if (!changes.length) return json({ error: "No editable fields supplied" }, 400);
|
|
13
|
+
const requestedStatus = changes.find(([key]) => key === "status")?.[1] as string | undefined;
|
|
14
|
+
const transitions: Record<string, string[]> = { draft: ["draft", "review", "archived"], review: ["review", "draft", "approved", "archived", "scheduled"], approved: ["approved", "review", "archived", "scheduled"], scheduled: ["scheduled", "approved", "published", "archived"], published: ["published", "archived"], archived: ["archived", "draft"] };
|
|
15
|
+
if (requestedStatus && !transitions[existing.status]?.includes(requestedStatus)) return json({ error: `Invalid transition ${existing.status} -> ${requestedStatus}` }, 409);
|
|
16
|
+
const set = changes.map(([key]) => `${key}=?`).join(", ");
|
|
17
|
+
try { const timestamp = now(); const version = Number((db().prepare("SELECT MAX(version) version FROM post_revisions WHERE post_id=?").get(params.id) as any)?.version || 0) + 1; db().prepare("INSERT INTO post_revisions (id,post_id,version,snapshot_json,actor_id,reason,created_at) VALUES (?,?,?,?,?,?,?)").run(id("revision"), params.id, version, JSON.stringify(existing), body?.actorId || "maggie-ops", body?.reason || "post edit", timestamp); db().prepare(`UPDATE posts SET ${set}, updated_at=? WHERE id=?`).run(...changes.map(([, value]) => value), timestamp, params.id); audit("post.updated", "post", params.id!, existing, changes); return json({ ok: true, revision: version }); } catch (error) { return json({ error: error instanceof Error ? error.message : "post could not be updated" }, 409); }
|
|
18
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { audit, db, id, now } from "../../../../lib/db";
|
|
3
|
+
import { json } from "../../../../lib/ops-auth";
|
|
4
|
+
|
|
5
|
+
export const GET: APIRoute = ({ url }) => {
|
|
6
|
+
const status = url.searchParams.get("status");
|
|
7
|
+
const rows = db().prepare(`SELECT p.id,p.source,p.source_id,p.slug,p.title,p.excerpt,p.canonical_url,p.language,p.status,p.published_at,p.updated_at,a.name author_name FROM posts p JOIN authors a ON a.id=p.author_id ${status ? "WHERE p.status=?" : ""} ORDER BY COALESCE(p.updated_at,p.created_at) DESC`).all(...(status ? [status] : []));
|
|
8
|
+
return json({ items: rows });
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const POST: APIRoute = async ({ request }) => {
|
|
12
|
+
const body = await request.json().catch(() => null) as any;
|
|
13
|
+
if (!body?.title || !body?.slug || !body?.canonicalUrl || !body?.authorId) return json({ error: "title, slug, canonicalUrl and authorId are required" }, 400);
|
|
14
|
+
const postId = body.id || id("post");
|
|
15
|
+
const timestamp = now();
|
|
16
|
+
if (body.status && body.status !== "draft") return json({ error: "New posts always start as draft" }, 409);
|
|
17
|
+
let customMetadata = "{}"; try { customMetadata = JSON.stringify(typeof body.customMetadata === "string" ? JSON.parse(body.customMetadata) : (body.customMetadata || {})); } catch { return json({ error: "customMetadata must be valid JSON" }, 400); }
|
|
18
|
+
try {
|
|
19
|
+
const transaction = db().transaction(() => {
|
|
20
|
+
db().prepare("INSERT INTO posts (id,source,source_id,slug,title,excerpt,content_markdown,content_html,canonical_url,cover_image_url,cover_image_alt,author_id,language,status,published_at,updated_at,created_at,seo_title,meta_description,focus_keyword,canonical_override,robots_index,robots_follow,og_image_url,schema_type,custom_metadata_json) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)").run(postId, body.source || "manual", body.sourceId || null, body.slug, body.title, body.excerpt || "", body.contentMarkdown || null, body.contentHtml || "", body.canonicalUrl, body.coverImageUrl || null, body.coverImageAlt || "", body.authorId, body.language || "en", "draft", null, timestamp, timestamp, body.seoTitle || null, body.metaDescription || null, body.focusKeyword || null, body.canonicalOverride || null, body.robotsIndex || "index", body.robotsFollow || "follow", body.ogImageUrl || null, body.schemaType || "Article", customMetadata);
|
|
21
|
+
for (const topicId of Array.isArray(body.topicIds) ? body.topicIds : []) db().prepare("INSERT INTO post_topics (post_id,topic_id) VALUES (?,?)").run(postId, topicId);
|
|
22
|
+
if (body.ctaId) db().prepare("INSERT INTO post_ctas (post_id,cta_id) VALUES (?,?)").run(postId, body.ctaId);
|
|
23
|
+
});
|
|
24
|
+
transaction();
|
|
25
|
+
audit("post.created", "post", postId, null, { status: "draft", slug: body.slug });
|
|
26
|
+
return json({ id: postId }, 201);
|
|
27
|
+
} catch (error) { return json({ error: error instanceof Error ? error.message : "post could not be created" }, 409); }
|
|
28
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { emailProviderStatus } from "../../../lib/providers/email";
|
|
3
|
+
import { mediaProviderStatus } from "../../../lib/providers/media-storage";
|
|
4
|
+
import { json } from "../../../lib/ops-auth";
|
|
5
|
+
|
|
6
|
+
export const GET: APIRoute = () => json({ email: emailProviderStatus(), media: mediaProviderStatus(), generatedAt: new Date().toISOString() });
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { aiCmoRequest } from "../../../../lib/ai-cmo";
|
|
3
|
+
import { audit, db, id, now } from "../../../../lib/db";
|
|
4
|
+
import { json } from "../../../../lib/ops-auth";
|
|
5
|
+
|
|
6
|
+
const contentIdOf = (item: Record<string, unknown>) => String(item.content_id || item.contentId || item.id || "");
|
|
7
|
+
const bodyText = (item: Record<string, unknown>) => String(item.content_html || item.contentHtml || item.content_markdown || item.contentMarkdown || item.content || "");
|
|
8
|
+
|
|
9
|
+
export const POST: APIRoute = async () => {
|
|
10
|
+
const upstream = await aiCmoRequest("/updates", "GET");
|
|
11
|
+
if (!upstream.ok) return upstream;
|
|
12
|
+
const payload = await upstream.json().catch(() => null) as Record<string, unknown> | unknown[] | null;
|
|
13
|
+
const source = Array.isArray(payload) ? null : payload;
|
|
14
|
+
const items = Array.isArray(payload) ? payload : Array.isArray(source?.updates) ? source.updates : Array.isArray(source?.items) ? source.items : [];
|
|
15
|
+
const timestamp = now();
|
|
16
|
+
let applied = 0;
|
|
17
|
+
let skipped = 0;
|
|
18
|
+
const report: Array<{ contentId: string; status: string }> = [];
|
|
19
|
+
const transaction = db().transaction(() => {
|
|
20
|
+
for (const raw of items) {
|
|
21
|
+
if (!raw || typeof raw !== "object") { skipped++; continue; }
|
|
22
|
+
const item = raw as Record<string, unknown>;
|
|
23
|
+
const contentId = contentIdOf(item);
|
|
24
|
+
const post = contentId ? db().prepare("SELECT * FROM posts WHERE source='api-pull' AND source_id=?").get(contentId) as Record<string, unknown> | undefined : undefined;
|
|
25
|
+
const content = bodyText(item);
|
|
26
|
+
if (!post || !content) { skipped++; continue; }
|
|
27
|
+
const version = Number((db().prepare("SELECT MAX(version) version FROM post_revisions WHERE post_id=?").get(post.id) as { version?: number } | undefined)?.version || 0) + 1;
|
|
28
|
+
db().prepare("INSERT INTO post_revisions (id,post_id,version,snapshot_json,actor_id,reason,created_at) VALUES (?,?,?,?,?,?,?)").run(id("revision"), post.id, version, JSON.stringify(post), "api-pull", "API Pull rewrite update", timestamp);
|
|
29
|
+
const nextExcerpt = item.excerpt || item.description || post.excerpt || "";
|
|
30
|
+
db().prepare("UPDATE posts SET excerpt=?,content_html=?,updated_at=?,checksum=COALESCE(?,checksum) WHERE id=?").run(String(nextExcerpt), content, timestamp, item.content_hash ? String(item.content_hash) : null, post.id);
|
|
31
|
+
db().prepare("INSERT INTO content_delivery_state (post_id,remote_content_id,remote_version,delivery_status,last_reported_url,last_reported_hash,last_error,updated_at) VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(post_id) DO UPDATE SET remote_content_id=excluded.remote_content_id,remote_version=excluded.remote_version,delivery_status=excluded.delivery_status,last_reported_hash=excluded.last_reported_hash,last_error=NULL,updated_at=excluded.updated_at").run(post.id, contentId, item.version ? String(item.version) : null, "received", post.canonical_url, item.content_hash ? String(item.content_hash) : null, null, timestamp);
|
|
32
|
+
audit("api_pull.update.applied", "post", String(post.id), null, { contentId, version });
|
|
33
|
+
applied++;
|
|
34
|
+
report.push({ contentId, status: String(post.status || "draft") });
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
try { transaction(); } catch (error) { return json({ error: error instanceof Error ? error.message : "API Pull update sync failed", applied, skipped }, 409); }
|
|
38
|
+
for (const item of report) {
|
|
39
|
+
const post = db().prepare("SELECT canonical_url,checksum FROM posts WHERE source='api-pull' AND source_id=?").get(item.contentId) as { canonical_url: string; checksum?: string } | undefined;
|
|
40
|
+
if (post) await aiCmoRequest("/content-tracking/report-state", "POST", { content_id: item.contentId, url: post.canonical_url, status: item.status, content_hash: post.checksum }).catch(() => undefined);
|
|
41
|
+
}
|
|
42
|
+
return json({ applied, skipped, total: items.length, status: "completed" });
|
|
43
|
+
};
|