@topy-ai/maggie 0.7.33 → 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 +16 -3
- 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,7 @@
|
|
|
1
|
+
import { defineMiddleware } from "astro:middleware";
|
|
2
|
+
import { opsAuth } from "./lib/ops-auth";
|
|
3
|
+
|
|
4
|
+
export const onRequest = defineMiddleware(({ url, request }, next) => {
|
|
5
|
+
if (!url.pathname.startsWith("/ops") && !url.pathname.startsWith("/api/ops")) return next();
|
|
6
|
+
return opsAuth(request, url.pathname) || next();
|
|
7
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
import BaseLayout from "../layouts/BaseLayout.astro";
|
|
3
|
+
import { siteUrl } from "../lib/repository";
|
|
4
|
+
---
|
|
5
|
+
<BaseLayout title="Page not found" description="The requested page could not be found." canonical={siteUrl("/404")} noindex>
|
|
6
|
+
<section aria-labelledby="not-found-title">
|
|
7
|
+
<h1 id="not-found-title">Page not found</h1>
|
|
8
|
+
<p>The page may have moved or no longer be published.</p>
|
|
9
|
+
<a href="/blog">Return to the blog</a>
|
|
10
|
+
</section>
|
|
11
|
+
</BaseLayout>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
import BaseLayout from "../layouts/BaseLayout.astro";
|
|
3
|
+
import { db } from "../lib/db";
|
|
4
|
+
import { siteUrl } from "../lib/repository";
|
|
5
|
+
import { organizationJsonLd } from "../lib/seo";
|
|
6
|
+
const configured = db().prepare("SELECT name,same_as_json,schema_json FROM entity_profiles WHERE entity_type='organization' ORDER BY updated_at DESC LIMIT 1").get() as any;
|
|
7
|
+
let sameAs: string[] = [];
|
|
8
|
+
try { sameAs = JSON.parse(configured?.same_as_json || "[]"); } catch { sameAs = []; }
|
|
9
|
+
const name = configured?.name || import.meta.env.PUBLIC_SITE_NAME;
|
|
10
|
+
const organization = name ? organizationJsonLd({ name, url: siteUrl("/about"), logo: configured?.schema_json ? (() => { try { return JSON.parse(configured.schema_json).logo; } catch { return undefined; } })() : undefined, sameAs }) : null;
|
|
11
|
+
---
|
|
12
|
+
<BaseLayout title="About" description={name ? `About ${name}.` : "About this site."} canonical={siteUrl("/about")} jsonLd={organization || undefined}>
|
|
13
|
+
<h1>About{ name ? ` ${name}` : "" }</h1>
|
|
14
|
+
<p>This site publishes maintained, attributable guidance and clearly separates editorial review from automated operations.</p>
|
|
15
|
+
</BaseLayout>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { audit, db, now } from "../../../lib/db";
|
|
4
|
+
import { json } from "../../../lib/ops-auth";
|
|
5
|
+
|
|
6
|
+
function find(token: string): any {
|
|
7
|
+
return db().prepare("SELECT l.*,p.title,p.slug,p.excerpt,p.content_html,p.canonical_url FROM approval_links l JOIN posts p ON p.id=l.post_id WHERE l.token_hash=?").get(createHash("sha256").update(token).digest("hex"));
|
|
8
|
+
}
|
|
9
|
+
export const GET: APIRoute = ({ params }) => {
|
|
10
|
+
const link = find(params.token || "");
|
|
11
|
+
if (!link || link.status === "expired" || Date.parse(link.expires_at) < Date.now()) return json({ error: "approval link is invalid or expired" }, 404);
|
|
12
|
+
if (link.status !== "pending") return json({ error: "approval link has already been resolved" }, 409);
|
|
13
|
+
const comments = db().prepare("SELECT id,paragraph_key,body,author_name,created_at FROM approval_comments WHERE approval_link_id=? ORDER BY created_at").all(link.id);
|
|
14
|
+
return json({ id: link.id, post: { title: link.title, slug: link.slug, excerpt: link.excerpt, contentHtml: link.content_html, canonicalUrl: link.canonical_url }, status: link.status, expiresAt: link.expires_at, reviewerName: link.reviewer_name, comments });
|
|
15
|
+
};
|
|
16
|
+
export const POST: APIRoute = async ({ params, request }) => {
|
|
17
|
+
const link = find(params.token || "");
|
|
18
|
+
if (!link || link.status === "expired" || Date.parse(link.expires_at) < Date.now()) return json({ error: "approval link is invalid or expired" }, 404);
|
|
19
|
+
const body = await request.json().catch(() => null) as any; const decision = body?.decision;
|
|
20
|
+
if (decision !== "approved" && decision !== "changes_requested") return json({ error: "decision must be approved or changes_requested" }, 400);
|
|
21
|
+
const timestamp = now(); db().prepare("UPDATE approval_links SET status=?,comment=?,approved_at=? WHERE id=?").run(decision, body.comment || null, decision === "approved" ? timestamp : null, link.id);
|
|
22
|
+
if (decision === "approved") db().prepare("UPDATE posts SET status=CASE WHEN status='review' THEN 'approved' ELSE status END,updated_at=? WHERE id=?").run(timestamp, link.post_id);
|
|
23
|
+
audit(`approval.${decision}`, "post", link.post_id, { linkId: link.id }, { decision, comment: body.comment || null });
|
|
24
|
+
return json({ ok: true, status: decision });
|
|
25
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
|
|
3
|
+
export const POST: APIRoute = async ({ request, cookies }) => {
|
|
4
|
+
const body = await request.json().catch(() => null) as any;
|
|
5
|
+
if (body?.analytics !== true) return new Response(JSON.stringify({ error: "analytics consent is required" }), { status: 400, headers: { "content-type": "application/json" } });
|
|
6
|
+
cookies.set("analytics_consent", "granted", { path: "/", maxAge: 60 * 60 * 24 * 180, sameSite: "lax", secure: true, httpOnly: false });
|
|
7
|
+
return new Response(JSON.stringify({ ok: true }), { headers: { "content-type": "application/json" } });
|
|
8
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
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 secretKey = /(key|token|secret|password|credential)/i;
|
|
6
|
+
const redact = (value: unknown): unknown => Array.isArray(value) ? value.map(redact) : value && typeof value === "object" ? Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([key, entry]) => [key, secretKey.test(key) ? "[redacted]" : redact(entry)])) : value;
|
|
7
|
+
const workspace = (workspaceId: string | null) => workspaceId ? db().prepare("SELECT * FROM workspaces WHERE id=?").get(workspaceId) as any : undefined;
|
|
8
|
+
const relation = (table: string, idValue: unknown, workspaceId: string) => db().prepare(`SELECT id FROM ${table} WHERE id=? AND workspace_id=?`).get(String(idValue), workspaceId);
|
|
9
|
+
|
|
10
|
+
export const GET: APIRoute = ({ url }) => {
|
|
11
|
+
const workspaceId = url.searchParams.get("workspaceId");
|
|
12
|
+
if (!workspaceId || !workspace(workspaceId)) return json({ error: "valid workspaceId is required" }, 400);
|
|
13
|
+
const result = {
|
|
14
|
+
workspace: workspace(workspaceId),
|
|
15
|
+
clients: db().prepare("SELECT * FROM clients WHERE workspace_id=? ORDER BY name").all(workspaceId),
|
|
16
|
+
sites: (db().prepare("SELECT id,workspace_id,client_id,name,domain,settings_json,created_at,updated_at FROM sites WHERE workspace_id=? ORDER BY name").all(workspaceId) as any[]).map((site) => ({ ...site, settings: redact(JSON.parse(site.settings_json || "{}")) })),
|
|
17
|
+
projects: db().prepare("SELECT * FROM projects WHERE workspace_id=? ORDER BY name").all(workspaceId),
|
|
18
|
+
templates: db().prepare("SELECT * FROM workspace_templates WHERE workspace_id=? ORDER BY name").all(workspaceId),
|
|
19
|
+
members: db().prepare("SELECT workspace_id,user_id,role,created_at FROM workspace_members WHERE workspace_id=? ORDER BY user_id").all(workspaceId),
|
|
20
|
+
reportDeliveries: db().prepare("SELECT id,workspace_id,report_type,recipient,format,schedule,status,last_sent_at,last_error,created_at FROM report_deliveries WHERE workspace_id=? ORDER BY created_at DESC").all(workspaceId),
|
|
21
|
+
};
|
|
22
|
+
return json(result);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const POST: APIRoute = async ({ request }) => {
|
|
26
|
+
const body = await request.json().catch(() => null) as any;
|
|
27
|
+
if (!body?.workspaceId || !workspace(body.workspaceId)) return json({ error: "valid workspaceId is required" }, 400);
|
|
28
|
+
const workspaceId = String(body.workspaceId); const timestamp = now();
|
|
29
|
+
try {
|
|
30
|
+
if (body.action === "client") {
|
|
31
|
+
if (!body.name) return json({ error: "name is required" }, 400); const clientId = id("client"); db().prepare("INSERT INTO clients (id,workspace_id,name,email,created_at,updated_at) VALUES (?,?,?,?,?,?)").run(clientId, workspaceId, body.name, body.email || null, timestamp, timestamp); audit("agency.client.created", "client", clientId, null, { workspaceId, name: body.name }); return json({ id: clientId }, 201);
|
|
32
|
+
}
|
|
33
|
+
if (body.action === "site") {
|
|
34
|
+
if (!body.name || !body.domain) return json({ error: "name and domain are required" }, 400); if (body.clientId && !relation("clients", body.clientId, workspaceId)) return json({ error: "client does not belong to workspace" }, 409); if (body.credentials) return json({ error: "credentials must use 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, workspaceId, body.clientId || null, body.name, body.domain, JSON.stringify(redact(body.settings || {})), JSON.stringify({ provider: body.credentialsProvider || null, reference: body.credentialsRef || null }), timestamp, timestamp); audit("agency.site.created", "site", siteId, null, { workspaceId, domain: body.domain }); return json({ id: siteId }, 201);
|
|
35
|
+
}
|
|
36
|
+
if (body.action === "project") {
|
|
37
|
+
if (!body.name) return json({ error: "name is required" }, 400); if (body.siteId && !relation("sites", body.siteId, workspaceId)) return json({ error: "site does not belong to workspace" }, 409); const projectId = id("project"); db().prepare("INSERT INTO projects (id,workspace_id,site_id,name,status,created_at,updated_at) VALUES (?,?,?,?,?,?,?)").run(projectId, workspaceId, body.siteId || null, body.name, body.status || "active", timestamp, timestamp); audit("agency.project.created", "project", projectId, null, { workspaceId }); return json({ id: projectId }, 201);
|
|
38
|
+
}
|
|
39
|
+
if (body.action === "template") {
|
|
40
|
+
if (!body.name || !body.templateType) return json({ error: "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, workspaceId, body.name, body.templateType, JSON.stringify(body.content || {}), timestamp, timestamp); audit("agency.template.updated", "workspace_template", templateId, null, { workspaceId }); return json({ id: templateId }, 201);
|
|
41
|
+
}
|
|
42
|
+
if (body.action === "member") {
|
|
43
|
+
const roles = ["agency-owner", "account-manager", "seo-strategist", "writer", "editor", "developer", "client-reviewer", "read-only-analyst"]; if (!body.userId || !roles.includes(body.role)) return json({ error: "userId and 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(workspaceId, body.userId, body.role, timestamp); audit("agency.member.updated", "workspace_member", `${workspaceId}:${body.userId}`, null, { workspaceId, userId: body.userId, role: body.role }); return json({ ok: true });
|
|
44
|
+
}
|
|
45
|
+
return json({ error: "action must be client, site, project, template, or member" }, 400);
|
|
46
|
+
} catch (error) { return json({ error: error instanceof Error ? error.message : "agency operation failed" }, 409); }
|
|
47
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
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 = () => json({ items: db().prepare("SELECT id,slug,name,job_title,organisation,bio,profile_url,credentials_json,updated_at FROM authors ORDER BY name").all() });
|
|
6
|
+
export const POST: APIRoute = async ({ request }) => {
|
|
7
|
+
const body = await request.json().catch(() => null) as any;
|
|
8
|
+
if (!body?.slug || !body?.name) return json({ error: "slug and name are required" }, 400);
|
|
9
|
+
const authorId = id("author"); const timestamp = now();
|
|
10
|
+
try { db().prepare("INSERT INTO authors (id,slug,name,job_title,organisation,bio,profile_url,credentials_json,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)").run(authorId, body.slug, body.name, body.jobTitle || null, body.organisation || null, body.bio || null, body.profileUrl || null, JSON.stringify(body.credentials || []), timestamp, timestamp); audit("author.created", "author", authorId, null, body); return json({ id: authorId }, 201); } catch (error) { return json({ error: error instanceof Error ? error.message : "author could not be created" }, 409); }
|
|
11
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
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 allowedChanges = new Set(["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"]);
|
|
6
|
+
const slugify = (value: string) => value.toLowerCase().normalize("NFKD").replace(/[^\w\s-]/g, "").trim().replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 180) || "untitled";
|
|
7
|
+
function rowsFor(filter: any): any[] {
|
|
8
|
+
const clauses: string[] = []; const values: unknown[] = [];
|
|
9
|
+
if (Array.isArray(filter?.postIds) && filter.postIds.length) { clauses.push(`p.id IN (${filter.postIds.map(() => "?").join(",")})`); values.push(...filter.postIds.map(String)); }
|
|
10
|
+
for (const [key, column] of Object.entries({ status: "status", source: "source", authorId: "author_id", language: "language" })) if (filter?.[key] !== undefined) { clauses.push(`p.${column}=?`); values.push(filter[key]); }
|
|
11
|
+
return db().prepare(`SELECT p.* FROM posts p ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""} ORDER BY p.updated_at DESC LIMIT 1000`).all(...values) as any[];
|
|
12
|
+
}
|
|
13
|
+
function validateChanges(changes: any): string | null {
|
|
14
|
+
const invalid = Object.keys(changes || {}).filter((key) => !allowedChanges.has(key) && !["regenerateSlug", "regenerateDescription", "queueRewrite", "rewriteReason", "rewritePriority", "topicIds", "termIds"].includes(key));
|
|
15
|
+
if (invalid.length) return `unsupported changes: ${invalid.join(", ")}`;
|
|
16
|
+
if (changes?.status && !["draft", "review", "approved", "scheduled", "published", "archived"].includes(changes.status)) return "unsupported post status";
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
export const POST: APIRoute = async ({ request }) => {
|
|
20
|
+
const body = await request.json().catch(() => null) as any;
|
|
21
|
+
if (!body) return json({ error: "JSON body is required" }, 400);
|
|
22
|
+
if (body.mode === "apply") {
|
|
23
|
+
if (!body.previewId) return json({ error: "previewId is required" }, 400);
|
|
24
|
+
} else {
|
|
25
|
+
if (!body.filter || !body.changes) return json({ error: "filter and changes are required" }, 400);
|
|
26
|
+
const error = validateChanges(body.changes); if (error) return json({ error }, 400);
|
|
27
|
+
}
|
|
28
|
+
const rows = rowsFor(body.filter);
|
|
29
|
+
if (body.mode !== "apply") {
|
|
30
|
+
const previewId = id("bulk");
|
|
31
|
+
const impact = rows.map((post) => ({ id: post.id, slug: post.slug, title: post.title, status: post.status, changes: body.changes }));
|
|
32
|
+
db().prepare("INSERT INTO bulk_operations (id,operation,filter_json,changes_json,preview_json,status,actor_id,created_at) VALUES (?,?,?,?,?,?,?,?)").run(previewId, body.operation || "bulk", JSON.stringify(body.filter), JSON.stringify(body.changes), JSON.stringify({ count: rows.length, ids: rows.map((post) => post.id), impact }), "preview", body.actorId || "maggie-ops", now());
|
|
33
|
+
return json({ previewId, status: "preview", count: rows.length, impact });
|
|
34
|
+
}
|
|
35
|
+
if (!body.previewId) return json({ error: "previewId is required for apply" }, 400);
|
|
36
|
+
const preview = db().prepare("SELECT * FROM bulk_operations WHERE id=? AND status='preview'").get(body.previewId) as any;
|
|
37
|
+
if (!preview) return json({ error: "valid previewId is required" }, 409);
|
|
38
|
+
const previewIds = JSON.parse(preview.preview_json || "{}").ids || []; const currentRows = rowsFor(JSON.parse(preview.filter_json || "{}"));
|
|
39
|
+
if (JSON.stringify(currentRows.map((post) => post.id).sort()) !== JSON.stringify(previewIds.slice().sort())) return json({ error: "preview is stale; create a new preview" }, 409);
|
|
40
|
+
const changes = JSON.parse(preview.changes_json || "{}"); const timestamp = now();
|
|
41
|
+
const tx = db().transaction(() => {
|
|
42
|
+
const revision = db().prepare("INSERT INTO post_revisions (id,post_id,version,snapshot_json,actor_id,reason,created_at) VALUES (?,?,?,?,?,?,?)");
|
|
43
|
+
for (const post of currentRows) {
|
|
44
|
+
const version = Number((db().prepare("SELECT MAX(version) version FROM post_revisions WHERE post_id=?").get(post.id) as any)?.version || 0) + 1;
|
|
45
|
+
revision.run(id("revision"), post.id, version, JSON.stringify(post), body.actorId || "maggie-ops", "bulk preview apply", timestamp);
|
|
46
|
+
const fields = [...Object.keys(changes).filter((key) => allowedChanges.has(key))]; const values = fields.map((key) => changes[key]);
|
|
47
|
+
if (changes.status === "published" && !post.published_at && !fields.includes("published_at")) { fields.push("published_at"); values.push(timestamp); }
|
|
48
|
+
if (changes.regenerateSlug) { const next = slugify(post.title); if (!db().prepare("SELECT 1 FROM posts WHERE slug=? AND id!=?").get(next, post.id)) { fields.push("slug"); values.push(next); } }
|
|
49
|
+
if (changes.regenerateDescription) { fields.push("excerpt"); values.push(String(post.content_html || "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim().slice(0, 155)); }
|
|
50
|
+
if (fields.length) db().prepare(`UPDATE posts SET ${fields.map((field) => `${field}=?`).join(",")},updated_at=? WHERE id=?`).run(...values, timestamp, post.id);
|
|
51
|
+
if (changes.queueRewrite) 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").run(id("rewrite"), post.id, changes.rewriteReason || "bulk operation", "queued", Number(changes.rewritePriority || 0), body.actorId || "maggie-ops", timestamp, timestamp);
|
|
52
|
+
if (Array.isArray(changes.topicIds)) { db().prepare("DELETE FROM post_topics WHERE post_id=?").run(post.id); for (const topicId of changes.topicIds) db().prepare("INSERT OR IGNORE INTO post_topics (post_id,topic_id) VALUES (?,?)").run(post.id, topicId); }
|
|
53
|
+
if (Array.isArray(changes.termIds)) { db().prepare("DELETE FROM post_wp_terms WHERE post_id=?").run(post.id); for (const termId of changes.termIds) db().prepare("INSERT OR IGNORE INTO post_wp_terms (post_id,term_id) SELECT ?,id FROM wp_terms WHERE id=?").run(post.id, termId); }
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
tx(); db().prepare("UPDATE bulk_operations SET status='completed',completed_at=? WHERE id=?").run(timestamp, body.previewId); audit("bulk.preview_applied", "bulk_operation", body.previewId, null, { count: currentRows.length });
|
|
57
|
+
return json({ previewId: body.previewId, status: "completed", applied: currentRows.length });
|
|
58
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
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 = () => {
|
|
6
|
+
const timestamp = now();
|
|
7
|
+
const rows = db().prepare("SELECT p.id,p.title FROM posts p JOIN content_calendar c ON c.post_id=p.id WHERE p.status='scheduled' AND c.scheduled_at<=? AND p.published_at IS NULL").all(timestamp) as Array<{ id: string; title: string }>;
|
|
8
|
+
const publish = db().prepare("UPDATE posts SET status='published',published_at=?,updated_at=? WHERE id=?");
|
|
9
|
+
const tx = db().transaction(() => rows.forEach((post) => publish.run(timestamp, timestamp, post.id))); tx();
|
|
10
|
+
for (const post of rows) audit("post.scheduled_published", "post", post.id, { status: "approved" }, { status: "published" });
|
|
11
|
+
return json({ published: rows.length, posts: rows });
|
|
12
|
+
};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { runtimeConfig } from "../../../../lib/config";
|
|
3
|
+
import { json } from "../../../../lib/ops-auth";
|
|
4
|
+
export const GET: APIRoute = () => { try { return json({ valid: true, config: runtimeConfig() }); } catch (error) { return json({ valid: false, error: error instanceof Error ? error.message : "Invalid configuration" }, 422); } };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { aiCmoRequest } from "../../../../../lib/ai-cmo";
|
|
3
|
+
import { db, now } from "../../../../../lib/db";
|
|
4
|
+
import { json } from "../../../../../lib/ops-auth";
|
|
5
|
+
|
|
6
|
+
export const POST: APIRoute = async ({ request }) => {
|
|
7
|
+
const body = await request.json().catch(() => null) as Record<string, unknown> | null;
|
|
8
|
+
if (!Array.isArray(body?.items) || body.items.length === 0 || body.items.length > 500) return json({ error: "items must contain 1 to 500 records" }, 400);
|
|
9
|
+
const upstream = await aiCmoRequest("/content-tracking/report-state/batch", "POST", body);
|
|
10
|
+
if (!upstream.ok) return upstream;
|
|
11
|
+
const timestamp = now();
|
|
12
|
+
const update = 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_url=excluded.last_reported_url,last_reported_hash=excluded.last_reported_hash,last_error=NULL,updated_at=excluded.updated_at");
|
|
13
|
+
for (const item of body.items as Array<Record<string, unknown>>) {
|
|
14
|
+
const contentId = String(item.content_id || item.contentId || "");
|
|
15
|
+
const post = contentId ? db().prepare("SELECT id FROM posts WHERE source_id=? OR id=? LIMIT 1").get(contentId, contentId) as { id: string } | undefined : undefined;
|
|
16
|
+
if (post && item.url && item.status) update.run(post.id, contentId, item.version ? String(item.version) : null, String(item.status), String(item.url), item.content_hash ? String(item.content_hash) : null, null, timestamp);
|
|
17
|
+
}
|
|
18
|
+
return upstream;
|
|
19
|
+
};
|
package/bundled-templates/astro-blog/starter/src/pages/api/ops/content-tracking/report-state.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { db, now } from "../../../../lib/db";
|
|
3
|
+
import { aiCmoRequest } from "../../../../lib/ai-cmo";
|
|
4
|
+
import { json } from "../../../../lib/ops-auth";
|
|
5
|
+
|
|
6
|
+
export const POST: APIRoute = async ({ request }) => {
|
|
7
|
+
const body = await request.json().catch(() => null) as Record<string, unknown> | null;
|
|
8
|
+
const contentId = String(body?.content_id || body?.contentId || "");
|
|
9
|
+
if (!contentId || !body?.url || !body?.status) return json({ error: "content_id, url and status are required" }, 400);
|
|
10
|
+
const upstream = await aiCmoRequest("/content-tracking/report-state", "POST", body);
|
|
11
|
+
if (!upstream.ok) return upstream;
|
|
12
|
+
const post = db().prepare("SELECT id FROM posts WHERE source_id=? OR id=? LIMIT 1").get(contentId, contentId) as { id: string } | undefined;
|
|
13
|
+
if (post) 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_url=excluded.last_reported_url,last_reported_hash=excluded.last_reported_hash,last_error=NULL,updated_at=excluded.updated_at").run(post.id, contentId, body.version ? String(body.version) : null, String(body.status), String(body.url), body.content_hash ? String(body.content_hash) : null, null, now());
|
|
14
|
+
return upstream;
|
|
15
|
+
};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { audit, db, id, now } from "../../../lib/db";
|
|
3
|
+
import { json } from "../../../lib/ops-auth";
|
|
4
|
+
export const GET: APIRoute = () => json({ items: db().prepare("SELECT * FROM ctas WHERE status!='archived' ORDER BY placement,label").all() });
|
|
5
|
+
export const POST: APIRoute = async ({ request }) => { const body = await request.json().catch(() => null) as any; if (!body?.label || !body?.url || !body?.placement) return json({ error: "label, url and placement are required" }, 400); const ctaId = id("cta"); const timestamp = now(); try { db().prepare("INSERT INTO ctas (id,label,url,placement,tracking_key,status,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)").run(ctaId, body.label, body.url, body.placement, body.trackingKey || null, body.status || "draft", timestamp, timestamp); audit("cta.created", "cta", ctaId, null, body); return json({ id: ctaId }, 201); } catch (error) { return json({ error: error instanceof Error ? error.message : "CTA could not be created" }, 409); } };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { db, id, now } from "../../../lib/db";
|
|
3
|
+
import { json } from "../../../lib/ops-auth";
|
|
4
|
+
|
|
5
|
+
export const GET: APIRoute = () => json({
|
|
6
|
+
entities: db().prepare("SELECT * FROM entity_profiles ORDER BY entity_type,name").all(),
|
|
7
|
+
relations: db().prepare("SELECT r.subject_key,r.predicate,r.object_key,r.created_at,s.name subject_name,o.name object_name FROM entity_relations r JOIN entity_profiles s ON s.entity_key=r.subject_key JOIN entity_profiles o ON o.entity_key=r.object_key ORDER BY r.subject_key,r.predicate,r.object_key").all(),
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
export const POST: APIRoute = async ({ request }) => {
|
|
11
|
+
const body = await request.json().catch(() => null) as any;
|
|
12
|
+
if (!body?.entityType || !body?.entityKey || !body?.name) return json({ error: "entityType, entityKey and name are required" }, 400);
|
|
13
|
+
const timestamp = now();
|
|
14
|
+
try {
|
|
15
|
+
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 entity_type=excluded.entity_type,name=excluded.name,same_as_json=excluded.same_as_json,schema_json=excluded.schema_json,updated_at=excluded.updated_at").run(id("entity"), body.entityType, body.entityKey, body.name, JSON.stringify(body.sameAs || []), JSON.stringify(body.schema || {}), timestamp);
|
|
16
|
+
if (Array.isArray(body.relations)) for (const relation of body.relations) {
|
|
17
|
+
if (!relation?.predicate || !relation?.objectKey) continue;
|
|
18
|
+
if (!db().prepare("SELECT 1 FROM entity_profiles WHERE entity_key=?").get(relation.objectKey)) return json({ error: `object entity not found: ${relation.objectKey}` }, 409);
|
|
19
|
+
db().prepare("INSERT OR IGNORE INTO entity_relations (subject_key,predicate,object_key,created_at) VALUES (?,?,?,?)").run(body.entityKey, relation.predicate, relation.objectKey, timestamp);
|
|
20
|
+
}
|
|
21
|
+
return json({ entityKey: body.entityKey, relations: body.relations || [] }, 201);
|
|
22
|
+
} catch (error) { return json({ error: error instanceof Error ? error.message : "entity could not be saved" }, 409); }
|
|
23
|
+
};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { json } from "../../../../lib/ops-auth";
|
|
3
|
+
import { optimizeMedia } from "../../../../lib/media";
|
|
4
|
+
|
|
5
|
+
export const POST: APIRoute = async ({ request }) => { const body = await request.json().catch(() => null) as any; if (!body?.mediaId) return json({ error: "mediaId is required" }, 400); try { const formats = Array.isArray(body.formats) ? body.formats.filter((value: unknown): value is "webp" | "avif" => value === "webp" || value === "avif") : ["webp", "avif"]; return json(await optimizeMedia(body.mediaId, formats), 201); } catch (error) { return json({ error: error instanceof Error ? error.message : "media optimization failed" }, 422); } };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { db, now } from "../../../../lib/db";
|
|
5
|
+
import { json } from "../../../../lib/ops-auth";
|
|
6
|
+
import { getMediaStorageProvider, type MediaStorageProviderId } from "../../../../lib/providers/media-storage";
|
|
7
|
+
|
|
8
|
+
export const POST: APIRoute = async ({ request }) => {
|
|
9
|
+
const body = await request.json().catch(() => null) as Record<string, unknown> | null;
|
|
10
|
+
const mediaId = String(body?.mediaId || "");
|
|
11
|
+
if (!mediaId) return json({ error: "mediaId is required" }, 400);
|
|
12
|
+
const media = db().prepare("SELECT * FROM media_assets WHERE id=?").get(mediaId) as Record<string, unknown> | undefined;
|
|
13
|
+
if (!media) return json({ error: "media not found" }, 404);
|
|
14
|
+
const requested = String(body?.provider || import.meta.env.MEDIA_STORAGE_PROVIDER || "cloudinary").toLowerCase();
|
|
15
|
+
if (requested !== "cloudinary" && requested !== "local") return json({ error: "provider must be cloudinary or local" }, 400);
|
|
16
|
+
try {
|
|
17
|
+
const source = String(media.local_url || media.source_url || "");
|
|
18
|
+
const response = /^https?:\/\//.test(source) ? await fetch(source, { signal: AbortSignal.timeout(30000) }) : null;
|
|
19
|
+
if (response && !response.ok) throw new Error(`media source returned ${response.status}`);
|
|
20
|
+
const data = response ? Buffer.from(await response.arrayBuffer()) : await readFile(source.startsWith("/") ? resolve("public", source.slice(1)) : source);
|
|
21
|
+
if (data.byteLength > 25 * 1024 * 1024) return json({ error: "media upload exceeds 25 MB", mediaId }, 413);
|
|
22
|
+
const result = await getMediaStorageProvider(requested as MediaStorageProviderId).upload({ data, filename: String(body?.filename || media.source_id || mediaId), mimeType: String(media.mime_type || body?.mimeType || "application/octet-stream"), folder: body?.folder ? String(body.folder) : undefined, publicId: body?.publicId ? String(body.publicId) : undefined });
|
|
23
|
+
db().prepare("UPDATE media_assets SET storage_provider=?,storage_key=?,delivery_url=?,local_url=COALESCE(?,local_url),upload_status='uploaded',upload_error=NULL,updated_at=? WHERE id=?").run(result.provider, result.key, result.url, result.localPath || null, now(), mediaId);
|
|
24
|
+
return json({ ...result, mediaId }, 201);
|
|
25
|
+
} catch (error) {
|
|
26
|
+
const message = error instanceof Error ? error.message : "media upload failed";
|
|
27
|
+
db().prepare("UPDATE media_assets SET upload_status='failed',upload_error=?,updated_at=? WHERE id=?").run(message, now(), mediaId);
|
|
28
|
+
return json({ error: message, mediaId }, 502);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { db, now } from "../../../../../lib/db";
|
|
3
|
+
import { json } from "../../../../../lib/ops-auth";
|
|
4
|
+
|
|
5
|
+
export const POST: APIRoute = async ({ params, request, url }) => {
|
|
6
|
+
const migration = db().prepare("SELECT * FROM wp_migrations WHERE id=?").get(params.id) as any;
|
|
7
|
+
if (!migration) return json({ error: "migration not found" }, 404);
|
|
8
|
+
if (migration.status === "running") return json({ error: "migration is already running" }, 409);
|
|
9
|
+
const source = db().prepare("SELECT * FROM migration_sources WHERE location=? ORDER BY created_at DESC LIMIT 1").get(migration.source_url) as any;
|
|
10
|
+
if (!source) return json({ error: "persisted migration source not found" }, 409);
|
|
11
|
+
let sourceOptions: any = {};
|
|
12
|
+
try { sourceOptions = JSON.parse(source.options_json || "{}"); } catch { return json({ error: "persisted migration source options are invalid" }, 409); }
|
|
13
|
+
const options = JSON.parse(migration.options_json || "{}");
|
|
14
|
+
const payload: any = { ...sourceOptions, ...options, format: options.format || source.kind, mode: "apply" };
|
|
15
|
+
if (typeof sourceOptions.input === "string" && sourceOptions.input) payload.input = sourceOptions.input;
|
|
16
|
+
if (!payload.input && migration.source_url !== "inline") payload.sourceUrl = migration.source_url;
|
|
17
|
+
if (!payload.input && !payload.sourceUrl) return json({ error: "migration cannot resume without persisted input or sourceUrl" }, 409);
|
|
18
|
+
try {
|
|
19
|
+
const headers = new Headers({ "content-type": "application/json", origin: url.origin });
|
|
20
|
+
for (const name of ["authorization", "x-maggie-ops-token", "cookie"]) { const value = request.headers.get(name); if (value) headers.set(name, value); }
|
|
21
|
+
const response = await fetch(new URL("/api/ops/migrations/import", url.origin), { method: "POST", headers, body: JSON.stringify(payload) });
|
|
22
|
+
const text = await response.text(); let result: any; try { result = text ? JSON.parse(text) : {}; } catch { result = { raw: text }; }
|
|
23
|
+
if (!response.ok) return json({ id: params.id, status: "failed", error: result.error || "resumed migration failed", resumedResponse: result }, response.status);
|
|
24
|
+
const resumedId = result.id || null;
|
|
25
|
+
db().prepare("UPDATE wp_migrations SET checkpoint_json=?,counts_json=?,source_checksum=COALESCE(?,source_checksum),status='completed',completed_at=?,error=NULL WHERE id=?").run(JSON.stringify({ resumedFrom: params.id, resumedAs: resumedId, processed: result.counts?.total || result.total || 0, completed: true }), JSON.stringify(result.counts || { total: result.total || 0 }), result.sourceChecksum || null, now(), params.id);
|
|
26
|
+
return json({ ...result, resumedFrom: params.id, status: "completed" }, response.status);
|
|
27
|
+
} catch (error) { return json({ id: params.id, status: "failed", error: error instanceof Error ? error.message : "resume failed" }, 502); }
|
|
28
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { db } from "../../../../../lib/db";
|
|
3
|
+
import { json } from "../../../../../lib/ops-auth";
|
|
4
|
+
import { validateMigration } from "../../../../../lib/migration-validation";
|
|
5
|
+
|
|
6
|
+
export const POST: APIRoute = ({ params }) => { if (!db().prepare("SELECT 1 FROM wp_migrations WHERE id=?").get(params.id)) return json({ error: "migration not found" }, 404); return json(validateMigration(params.id!)); };
|
|
@@ -0,0 +1,7 @@
|
|
|
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 GET: APIRoute = ({ params }) => { const migration = db().prepare("SELECT * FROM wp_migrations WHERE id=?").get(params.id) as any; if (!migration) return json({ error: "migration not found" }, 404); return json({ ...migration, options: JSON.parse(migration.options_json || "{}"), counts: JSON.parse(migration.counts_json || "{}"), checkpoint: JSON.parse(migration.checkpoint_json || "{}"), items: db().prepare("SELECT * FROM wp_import_items WHERE migration_id=? ORDER BY created_at").all(params.id) }); };
|
|
6
|
+
function restoreSnapshot(postId: string, startedAt: string): boolean { const revision = db().prepare("SELECT snapshot_json FROM post_revisions WHERE post_id=? AND reason='Generic migration' AND created_at>=? ORDER BY created_at LIMIT 1").get(postId, startedAt) as { snapshot_json: string } | undefined; if (!revision) return false; const snapshot = JSON.parse(revision.snapshot_json); const fields = ["slug","title","excerpt","content_markdown","content_html","canonical_url","cover_image_url","cover_image_alt","author_id","language","status","published_at","updated_at","seo_title","meta_description","focus_keyword","canonical_override","robots_index","robots_follow","og_image_url","schema_type","custom_metadata_json"]; const present = fields.filter((field) => snapshot[field] !== undefined); if (!present.length) return false; db().prepare(`UPDATE posts SET ${present.map((field) => `${field}=?`).join(",")} WHERE id=?`).run(...present.map((field) => snapshot[field]), postId); return true; }
|
|
7
|
+
export const POST: APIRoute = async ({ params, request }) => { const body = await request.json().catch(() => null) as any; if (body?.action !== "rollback") return json({ error: "action must be rollback" }, 400); const migration = db().prepare("SELECT * FROM wp_migrations WHERE id=?").get(params.id) as any; if (!migration) return json({ error: "migration not found" }, 404); const items = db().prepare("SELECT target_id FROM wp_import_items WHERE migration_id=? AND entity_type='post' AND action='created' AND target_id IS NOT NULL").all(params.id) as Array<{ target_id: string }>; const updated = db().prepare("SELECT target_id FROM wp_import_items WHERE migration_id=? AND entity_type='post' AND action='updated' AND target_id IS NOT NULL").all(params.id) as Array<{ target_id: string }>; const tx = db().transaction(() => { const remove = db().prepare("DELETE FROM posts WHERE id=? AND source='cms'"); for (const item of items) remove.run(item.target_id); return updated.filter((item) => restoreSnapshot(item.target_id, migration.started_at)).length; }); const restored = tx(); db().prepare("UPDATE wp_migrations SET status='cancelled',error=?,completed_at=? WHERE id=?").run("rolled back by operator", now(), params.id); audit("migration.rolled_back", "migration", params.id!, null, { removedPosts: items.length, restoredUpdatedPosts: restored }); return json({ id: params.id, status: "cancelled", removedPosts: items.length, restoredUpdatedPosts: restored }); };
|