@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.
Files changed (138) hide show
  1. package/README-zh-TW.md +2 -2
  2. package/README.md +19 -4
  3. package/bundled-skills/maggie-blog-bootstrap/SKILL.md +8 -0
  4. package/bundled-skills/maggie-clone/SKILL.md +7 -1
  5. package/bundled-templates/astro-blog/README.md +217 -0
  6. package/bundled-templates/astro-blog/examples/blog.config.ts +30 -0
  7. package/bundled-templates/astro-blog/examples/content.config.ts +28 -0
  8. package/bundled-templates/astro-blog/examples/metadata.ts +37 -0
  9. package/bundled-templates/astro-blog/examples/ops-api.ts +31 -0
  10. package/bundled-templates/astro-blog/examples/route-manifest.ts +21 -0
  11. package/bundled-templates/astro-blog/starter/.env.example +36 -0
  12. package/bundled-templates/astro-blog/starter/README.md +168 -0
  13. package/bundled-templates/astro-blog/starter/astro.config.mjs +19 -0
  14. package/bundled-templates/astro-blog/starter/db/schema.sql +508 -0
  15. package/bundled-templates/astro-blog/starter/package-lock.json +8039 -0
  16. package/bundled-templates/astro-blog/starter/package.json +41 -0
  17. package/bundled-templates/astro-blog/starter/scripts/contract-check.mjs +16 -0
  18. package/bundled-templates/astro-blog/starter/scripts/fixture-seed.mjs +11 -0
  19. package/bundled-templates/astro-blog/starter/scripts/schema-check.mjs +13 -0
  20. package/bundled-templates/astro-blog/starter/scripts/seed-db.mjs +20 -0
  21. package/bundled-templates/astro-blog/starter/src/data/demo.ts +52 -0
  22. package/bundled-templates/astro-blog/starter/src/layouts/BaseLayout.astro +53 -0
  23. package/bundled-templates/astro-blog/starter/src/layouts/OpsLayout.astro +73 -0
  24. package/bundled-templates/astro-blog/starter/src/lib/ai-cmo.ts +14 -0
  25. package/bundled-templates/astro-blog/starter/src/lib/config.ts +15 -0
  26. package/bundled-templates/astro-blog/starter/src/lib/db.ts +95 -0
  27. package/bundled-templates/astro-blog/starter/src/lib/google.ts +28 -0
  28. package/bundled-templates/astro-blog/starter/src/lib/media.ts +12 -0
  29. package/bundled-templates/astro-blog/starter/src/lib/migration-validation.ts +11 -0
  30. package/bundled-templates/astro-blog/starter/src/lib/migration.ts +21 -0
  31. package/bundled-templates/astro-blog/starter/src/lib/ops-auth.ts +21 -0
  32. package/bundled-templates/astro-blog/starter/src/lib/providers/email.ts +45 -0
  33. package/bundled-templates/astro-blog/starter/src/lib/providers/media-storage.ts +37 -0
  34. package/bundled-templates/astro-blog/starter/src/lib/report-delivery.ts +26 -0
  35. package/bundled-templates/astro-blog/starter/src/lib/report-pdf.ts +49 -0
  36. package/bundled-templates/astro-blog/starter/src/lib/repository.ts +72 -0
  37. package/bundled-templates/astro-blog/starter/src/lib/seo-audit.ts +72 -0
  38. package/bundled-templates/astro-blog/starter/src/lib/seo-insights.ts +14 -0
  39. package/bundled-templates/astro-blog/starter/src/lib/seo.ts +33 -0
  40. package/bundled-templates/astro-blog/starter/src/lib/types.ts +58 -0
  41. package/bundled-templates/astro-blog/starter/src/lib/wordpress.ts +847 -0
  42. package/bundled-templates/astro-blog/starter/src/middleware.ts +7 -0
  43. package/bundled-templates/astro-blog/starter/src/pages/404.astro +11 -0
  44. package/bundled-templates/astro-blog/starter/src/pages/about.astro +15 -0
  45. package/bundled-templates/astro-blog/starter/src/pages/api/approval/[token].ts +25 -0
  46. package/bundled-templates/astro-blog/starter/src/pages/api/consent.ts +8 -0
  47. package/bundled-templates/astro-blog/starter/src/pages/api/ops/agency.ts +47 -0
  48. package/bundled-templates/astro-blog/starter/src/pages/api/ops/authors.ts +11 -0
  49. package/bundled-templates/astro-blog/starter/src/pages/api/ops/bulk.ts +58 -0
  50. package/bundled-templates/astro-blog/starter/src/pages/api/ops/calendar/publish-due.ts +12 -0
  51. package/bundled-templates/astro-blog/starter/src/pages/api/ops/config/validate.ts +4 -0
  52. package/bundled-templates/astro-blog/starter/src/pages/api/ops/content-tracking/report-state/batch.ts +19 -0
  53. package/bundled-templates/astro-blog/starter/src/pages/api/ops/content-tracking/report-state.ts +15 -0
  54. package/bundled-templates/astro-blog/starter/src/pages/api/ops/ctas.ts +5 -0
  55. package/bundled-templates/astro-blog/starter/src/pages/api/ops/entities.ts +23 -0
  56. package/bundled-templates/astro-blog/starter/src/pages/api/ops/media/optimize.ts +5 -0
  57. package/bundled-templates/astro-blog/starter/src/pages/api/ops/media/upload.ts +30 -0
  58. package/bundled-templates/astro-blog/starter/src/pages/api/ops/migrations/[id]/resume.ts +28 -0
  59. package/bundled-templates/astro-blog/starter/src/pages/api/ops/migrations/[id]/validate.ts +6 -0
  60. package/bundled-templates/astro-blog/starter/src/pages/api/ops/migrations/[id].ts +7 -0
  61. package/bundled-templates/astro-blog/starter/src/pages/api/ops/migrations/import.ts +458 -0
  62. package/bundled-templates/astro-blog/starter/src/pages/api/ops/migrations/media.ts +5 -0
  63. package/bundled-templates/astro-blog/starter/src/pages/api/ops/operations.ts +189 -0
  64. package/bundled-templates/astro-blog/starter/src/pages/api/ops/playbooks/[id]/run.ts +55 -0
  65. package/bundled-templates/astro-blog/starter/src/pages/api/ops/posts/[id]/approve.ts +13 -0
  66. package/bundled-templates/astro-blog/starter/src/pages/api/ops/posts/[id]/preview.ts +9 -0
  67. package/bundled-templates/astro-blog/starter/src/pages/api/ops/posts/[id]/publish.ts +13 -0
  68. package/bundled-templates/astro-blog/starter/src/pages/api/ops/posts/[id].ts +18 -0
  69. package/bundled-templates/astro-blog/starter/src/pages/api/ops/posts/index.ts +28 -0
  70. package/bundled-templates/astro-blog/starter/src/pages/api/ops/providers.ts +6 -0
  71. package/bundled-templates/astro-blog/starter/src/pages/api/ops/pull/history.ts +3 -0
  72. package/bundled-templates/astro-blog/starter/src/pages/api/ops/pull/latest.ts +3 -0
  73. package/bundled-templates/astro-blog/starter/src/pages/api/ops/pull/posts.ts +3 -0
  74. package/bundled-templates/astro-blog/starter/src/pages/api/ops/pull/project-context.ts +4 -0
  75. package/bundled-templates/astro-blog/starter/src/pages/api/ops/pull/sync-updates.ts +43 -0
  76. package/bundled-templates/astro-blog/starter/src/pages/api/ops/pull/sync.ts +172 -0
  77. package/bundled-templates/astro-blog/starter/src/pages/api/ops/pull/updates.ts +3 -0
  78. package/bundled-templates/astro-blog/starter/src/pages/api/ops/redirects/check.ts +5 -0
  79. package/bundled-templates/astro-blog/starter/src/pages/api/ops/redirects.ts +32 -0
  80. package/bundled-templates/astro-blog/starter/src/pages/api/ops/reports/deliver-due.ts +12 -0
  81. package/bundled-templates/astro-blog/starter/src/pages/api/ops/reports/deliver.ts +6 -0
  82. package/bundled-templates/astro-blog/starter/src/pages/api/ops/reports/export.ts +17 -0
  83. package/bundled-templates/astro-blog/starter/src/pages/api/ops/reports.ts +31 -0
  84. package/bundled-templates/astro-blog/starter/src/pages/api/ops/rewrite/history/[contentId].ts +3 -0
  85. package/bundled-templates/astro-blog/starter/src/pages/api/ops/rewrite/policy.ts +4 -0
  86. package/bundled-templates/astro-blog/starter/src/pages/api/ops/rewrite/queue.ts +4 -0
  87. package/bundled-templates/astro-blog/starter/src/pages/api/ops/seo/audit.ts +7 -0
  88. package/bundled-templates/astro-blog/starter/src/pages/api/ops/seo/insights.ts +5 -0
  89. package/bundled-templates/astro-blog/starter/src/pages/api/ops/settings/site.ts +7 -0
  90. package/bundled-templates/astro-blog/starter/src/pages/api/ops/settings.ts +19 -0
  91. package/bundled-templates/astro-blog/starter/src/pages/api/ops/sitemap/auto-detect.ts +13 -0
  92. package/bundled-templates/astro-blog/starter/src/pages/api/ops/sitemap/diff.ts +11 -0
  93. package/bundled-templates/astro-blog/starter/src/pages/api/ops/sitemap/match.ts +16 -0
  94. package/bundled-templates/astro-blog/starter/src/pages/api/ops/sitemap/matching-history.ts +4 -0
  95. package/bundled-templates/astro-blog/starter/src/pages/api/ops/sitemap/runs.ts +4 -0
  96. package/bundled-templates/astro-blog/starter/src/pages/api/ops/sitemap.ts +11 -0
  97. package/bundled-templates/astro-blog/starter/src/pages/api/ops/summary.ts +12 -0
  98. package/bundled-templates/astro-blog/starter/src/pages/api/ops/topics/[id]/faq.ts +12 -0
  99. package/bundled-templates/astro-blog/starter/src/pages/api/ops/topics.ts +11 -0
  100. package/bundled-templates/astro-blog/starter/src/pages/api/ops/wordpress/capabilities.ts +5 -0
  101. package/bundled-templates/astro-blog/starter/src/pages/api/ops/wordpress/migration-plan.ts +21 -0
  102. package/bundled-templates/astro-blog/starter/src/pages/api/ops/wordpress/migrations/[id]/resume.ts +5 -0
  103. package/bundled-templates/astro-blog/starter/src/pages/api/ops/wordpress/migrations/[id]/validate.ts +6 -0
  104. package/bundled-templates/astro-blog/starter/src/pages/api/ops/wordpress/migrations/[id].ts +13 -0
  105. package/bundled-templates/astro-blog/starter/src/pages/api/ops/wordpress/migrations.ts +147 -0
  106. package/bundled-templates/astro-blog/starter/src/pages/authors/[slug].astro +12 -0
  107. package/bundled-templates/astro-blog/starter/src/pages/blog/[slug].astro +25 -0
  108. package/bundled-templates/astro-blog/starter/src/pages/blog/index.astro +32 -0
  109. package/bundled-templates/astro-blog/starter/src/pages/blog/page/[page].astro +21 -0
  110. package/bundled-templates/astro-blog/starter/src/pages/ops/index.astro +20 -0
  111. package/bundled-templates/astro-blog/starter/src/pages/ops/operations.astro +33 -0
  112. package/bundled-templates/astro-blog/starter/src/pages/ops/posts/[id]/edit.astro +17 -0
  113. package/bundled-templates/astro-blog/starter/src/pages/ops/posts/[id]/preview.astro +11 -0
  114. package/bundled-templates/astro-blog/starter/src/pages/ops/posts/index.astro +15 -0
  115. package/bundled-templates/astro-blog/starter/src/pages/ops/posts/new.astro +12 -0
  116. package/bundled-templates/astro-blog/starter/src/pages/ops/reports.astro +12 -0
  117. package/bundled-templates/astro-blog/starter/src/pages/ops/rewrite.astro +13 -0
  118. package/bundled-templates/astro-blog/starter/src/pages/ops/settings/integrations.astro +13 -0
  119. package/bundled-templates/astro-blog/starter/src/pages/ops/settings/site.astro +11 -0
  120. package/bundled-templates/astro-blog/starter/src/pages/ops/sitemap.astro +15 -0
  121. package/bundled-templates/astro-blog/starter/src/pages/ops/topics.astro +13 -0
  122. package/bundled-templates/astro-blog/starter/src/pages/ops/wordpress.astro +34 -0
  123. package/bundled-templates/astro-blog/starter/src/pages/review/[token].astro +13 -0
  124. package/bundled-templates/astro-blog/starter/src/pages/robots.txt.ts +4 -0
  125. package/bundled-templates/astro-blog/starter/src/pages/sitemap.xml.ts +12 -0
  126. package/bundled-templates/astro-blog/starter/src/pages/topics/[slug]/page/[page].astro +10 -0
  127. package/bundled-templates/astro-blog/starter/src/pages/topics/[slug].astro +19 -0
  128. package/bundled-templates/astro-blog/starter/src/pages/topics/index.astro +10 -0
  129. package/bundled-templates/astro-blog/starter/src/styles/global.css +4 -0
  130. package/bundled-templates/astro-blog/starter/tsconfig.json +9 -0
  131. package/bundled-templates/astro-blog/starter/wrangler.jsonc +52 -0
  132. package/bundled-tools/clis/maggie_clone.py +42 -15
  133. package/bundled-tools/clis/maggie_dash.py +41 -25
  134. package/bundled-tools/clis/maggie_design.py +2 -0
  135. package/bundled-tools/clis/maggie_design_system.py +7 -1
  136. package/bundled-tools/clis/site_audit.py +25 -2
  137. package/bundled-tools/runtime/maggie_memory.py +6 -1
  138. package/package.json +1 -1
@@ -0,0 +1,172 @@
1
+ import type { APIRoute } from "astro";
2
+ import { createHash } from "node:crypto";
3
+ import { aiCmoRequest } from "../../../../lib/ai-cmo";
4
+ import { audit, db, id, now } from "../../../../lib/db";
5
+ import { json } from "../../../../lib/ops-auth";
6
+
7
+ const slugify = (value: string) =>
8
+ value
9
+ .toLowerCase()
10
+ .normalize("NFKD")
11
+ .replace(/[^\w\s-]/g, "")
12
+ .trim()
13
+ .replace(/[\s_-]+/g, "-")
14
+ .replace(/^-+|-+$/g, "")
15
+ .slice(0, 180) || "untitled";
16
+
17
+ export const POST: APIRoute = async () => {
18
+ const setting = db()
19
+ .prepare("SELECT value_json FROM blog_settings WHERE key='defaultAuthorId'")
20
+ .get() as { value_json: string } | undefined;
21
+ const authorId = setting ? JSON.parse(setting.value_json) : undefined;
22
+ if (
23
+ !authorId ||
24
+ !db().prepare("SELECT 1 FROM authors WHERE id=?").get(authorId)
25
+ )
26
+ return json(
27
+ { error: "Configure a valid defaultAuthorId before API Pull sync" },
28
+ 422,
29
+ );
30
+ const upstream = await aiCmoRequest("/posts", "GET");
31
+ const text = await upstream.text();
32
+ let payload: any;
33
+ try {
34
+ payload = JSON.parse(text);
35
+ } catch {
36
+ db()
37
+ .prepare(
38
+ "INSERT INTO content_sync_state (source,last_error,updated_at) VALUES (?,?,?) ON CONFLICT(source) DO UPDATE SET last_error=excluded.last_error, updated_at=excluded.updated_at",
39
+ )
40
+ .run("api-pull", "AI CMO returned invalid JSON", now());
41
+ return json({ error: "AI CMO returned invalid JSON" }, 502);
42
+ }
43
+ const items = Array.isArray(payload)
44
+ ? payload
45
+ : payload.posts || payload.items;
46
+ if (!Array.isArray(items)) {
47
+ db()
48
+ .prepare(
49
+ "INSERT INTO content_sync_state (source,last_error,updated_at) VALUES (?,?,?) ON CONFLICT(source) DO UPDATE SET last_error=excluded.last_error, updated_at=excluded.updated_at",
50
+ )
51
+ .run("api-pull", "AI CMO response did not contain posts or items", now());
52
+ return json(
53
+ { error: "AI CMO response did not contain posts or items" },
54
+ 502,
55
+ );
56
+ }
57
+ const timestamp = now();
58
+ let imported = 0;
59
+ let skipped = 0;
60
+ const transaction = db().transaction(() => {
61
+ for (const item of items) {
62
+ const sourceId = String(
63
+ item.id || item.content_id || item.source_id || "",
64
+ );
65
+ const title = String(item.title || "");
66
+ const requestedSlug = String(item.slug || slugify(title));
67
+ const content = String(item.content_html || item.contentHtml || item.content_markdown || item.contentMarkdown || "");
68
+ const sourceModified = item.modified_at || item.updated_at || item.updatedAt || item.generated_at || null;
69
+ const contentChecksum = String(item.content_hash || createHash("sha256").update(content).digest("hex"));
70
+ if (!sourceId || !requestedSlug || !title) {
71
+ skipped++;
72
+ continue;
73
+ }
74
+ const existing = db()
75
+ .prepare("SELECT * FROM posts WHERE source='api-pull' AND source_id=?")
76
+ .get(sourceId) as
77
+ ({ id: string } & Record<string, unknown>) | undefined;
78
+ const postId = existing?.id || id("post");
79
+ const slug = existing?.status === "published" ? String(existing.slug) : requestedSlug;
80
+ const canonical = existing?.status === "published" ? String(existing.canonical_url) : String(item.canonical_url || item.canonicalUrl || `${import.meta.env.PUBLIC_SITE_URL || "https://example.com"}/blog/${slug}`);
81
+ if (existing) {
82
+ const version =
83
+ Number(
84
+ (
85
+ db()
86
+ .prepare(
87
+ "SELECT MAX(version) version FROM post_revisions WHERE post_id=?",
88
+ )
89
+ .get(postId) as any
90
+ )?.version || 0,
91
+ ) + 1;
92
+ db()
93
+ .prepare(
94
+ "INSERT INTO post_revisions (id,post_id,version,snapshot_json,actor_id,reason,created_at) VALUES (?,?,?,?,?,?,?)",
95
+ )
96
+ .run(
97
+ id("revision"),
98
+ postId,
99
+ version,
100
+ JSON.stringify(existing),
101
+ "api-pull",
102
+ "API Pull sync",
103
+ timestamp,
104
+ );
105
+ db()
106
+ .prepare(
107
+ "UPDATE posts SET slug=?,title=?,excerpt=?,content_html=?,canonical_url=?,author_id=?,language=?,updated_at=?,checksum=?,source_modified_at=? WHERE id=?",
108
+ )
109
+ .run(
110
+ slug,
111
+ title,
112
+ String(item.excerpt || item.description || ""),
113
+ content,
114
+ canonical,
115
+ authorId,
116
+ String(item.language || "en"),
117
+ timestamp,
118
+ contentChecksum,
119
+ sourceModified ? String(sourceModified) : null,
120
+ postId,
121
+ );
122
+ } else
123
+ db()
124
+ .prepare(
125
+ "INSERT INTO posts (id,source,source_id,slug,title,excerpt,content_html,canonical_url,author_id,language,status,updated_at,created_at,checksum,source_modified_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
126
+ )
127
+ .run(
128
+ postId,
129
+ "api-pull",
130
+ sourceId,
131
+ slug,
132
+ title,
133
+ String(item.excerpt || item.description || ""),
134
+ content,
135
+ canonical,
136
+ authorId,
137
+ String(item.language || "en"),
138
+ "draft",
139
+ timestamp,
140
+ timestamp,
141
+ contentChecksum,
142
+ sourceModified ? String(sourceModified) : null,
143
+ );
144
+ audit(
145
+ existing ? "api_pull.post.updated" : "api_pull.post.created",
146
+ "post",
147
+ postId,
148
+ null,
149
+ { sourceId, slug },
150
+ );
151
+ imported++;
152
+ }
153
+ });
154
+ try {
155
+ transaction();
156
+ db()
157
+ .prepare(
158
+ "INSERT INTO content_sync_state (source,last_success_at,last_error,updated_at) VALUES (?,?,?,?) ON CONFLICT(source) DO UPDATE SET last_success_at=excluded.last_success_at, last_error=NULL, updated_at=excluded.updated_at",
159
+ )
160
+ .run("api-pull", timestamp, null, timestamp);
161
+ } catch (error) {
162
+ const message =
163
+ error instanceof Error ? error.message : "API Pull import failed";
164
+ db()
165
+ .prepare(
166
+ "INSERT INTO content_sync_state (source,last_error,updated_at) VALUES (?,?,?) ON CONFLICT(source) DO UPDATE SET last_error=excluded.last_error, updated_at=excluded.updated_at",
167
+ )
168
+ .run("api-pull", message, now());
169
+ return json({ error: message, imported, skipped }, 409);
170
+ }
171
+ return json({ imported, skipped, total: items.length, status: "completed" });
172
+ };
@@ -0,0 +1,3 @@
1
+ import type { APIRoute } from "astro";
2
+ import { aiCmoRequest } from "../../../../lib/ai-cmo";
3
+ export const POST: APIRoute = () => aiCmoRequest("/updates", "GET");
@@ -0,0 +1,5 @@
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 POST: APIRoute = async ({ request }) => { const body = await request.json().catch(() => null) as any; const rows = Array.isArray(body?.urls) ? body.urls.map((url: string) => ({ target: String(url), source: String(url) })) : (db().prepare("SELECT source_path,target_path FROM redirects").all() as Array<{ source_path: string; target_path: string }>).map((row) => ({ target: row.target_path, source: row.source_path })); if (!rows.length) return json({ checked: 0, broken: [] }); const broken: Array<{ url: string; error: string }> = []; for (const row of rows.slice(0, 200)) { try { const response = await fetch(new URL(row.target, import.meta.env.PUBLIC_SITE_URL || "https://example.com"), { method: "HEAD", redirect: "manual", signal: AbortSignal.timeout(10000) }); if (response.status >= 400) broken.push({ url: row.target, error: `HTTP ${response.status}` }); db().prepare("INSERT INTO indexability_checks (id,url,status,robots,canonical,checked_at,details_json) VALUES (?,?,?,?,?,?,?)").run(id("indexability"), row.target, response.ok ? "ok" : "broken", null, null, now(), JSON.stringify({ status: response.status, redirect_source: row.source })); } catch (error) { broken.push({ url: row.target, error: error instanceof Error ? error.message : "request failed" }); } } return json({ checked: Math.min(rows.length, 200), broken }); };
@@ -0,0 +1,32 @@
1
+ import type { APIRoute } from "astro";
2
+ import { db, id, now } from "../../../lib/db";
3
+ import { json } from "../../../lib/ops-auth";
4
+
5
+ const csvCell = (value: unknown) => `"${String(value ?? "").replaceAll('"', '""')}"`;
6
+ const parseCsv = (input: string) => {
7
+ const lines = input.split(/\r?\n/).filter((line) => line.trim());
8
+ if (!lines.length) return [] as Array<Record<string, string>>;
9
+ const cells = (line: string) => [...line.matchAll(/("(?:[^"]|"")*"|[^,]*)/g)].map((match) => match[1]).filter((value) => value !== undefined).map((value) => value.startsWith('"') ? value.slice(1, -1).replaceAll('""', '"') : value);
10
+ const headers = cells(lines[0]).map((value) => value.trim());
11
+ return lines.slice(1).map((line) => Object.fromEntries(headers.map((header, index) => [header, cells(line)[index] || ""])));
12
+ };
13
+
14
+ export const GET: APIRoute = ({ url }) => {
15
+ const rows = db().prepare("SELECT source_path,target_path,status_code,reason,created_at FROM redirects ORDER BY source_path").all() as any[];
16
+ if (url.searchParams.get("format") !== "csv") return json({ items: rows });
17
+ const csv = [["source_path", "target_path", "status_code", "reason", "created_at"], ...rows.map((row) => [row.source_path, row.target_path, row.status_code, row.reason, row.created_at])].map((row) => row.map(csvCell).join(",")).join("\n");
18
+ return new Response(csv + "\n", { headers: { "content-type": "text/csv; charset=utf-8", "content-disposition": "attachment; filename=redirects.csv" } });
19
+ };
20
+
21
+ export const POST: APIRoute = async ({ request }) => {
22
+ const contentType = request.headers.get("content-type") || "";
23
+ const raw = await request.text();
24
+ let rows: any[] = [];
25
+ if (contentType.includes("text/csv") || contentType.includes("application/csv")) rows = parseCsv(raw);
26
+ else { try { const body = JSON.parse(raw); rows = Array.isArray(body?.items) ? body.items : []; } catch { return json({ error: "body must be JSON {items} or CSV" }, 400); } }
27
+ if (!rows.length) return json({ error: "items is required" }, 400);
28
+ const timestamp = now(); let imported = 0; let skipped = 0;
29
+ const tx = db().transaction(() => { for (const row of rows) { const sourcePath = String(row.sourcePath || row.source_path || ""); const targetPath = String(row.targetPath || row.target_path || ""); if (!sourcePath.startsWith("/") || !targetPath.startsWith("/")) { skipped++; continue; } 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"), sourcePath, targetPath, Number(row.statusCode || row.status_code) === 308 ? 308 : 301, row.reason || "import", timestamp); imported++; } });
30
+ tx();
31
+ return json({ imported, skipped }, 201);
32
+ };
@@ -0,0 +1,12 @@
1
+ import type { APIRoute } from "astro";
2
+ import { db } from "../../../../lib/db";
3
+ import { json } from "../../../../lib/ops-auth";
4
+ import { deliverReport, isDeliveryDue } from "../../../../lib/report-delivery";
5
+
6
+ export const POST: APIRoute = async () => {
7
+ const deliveries = db().prepare("SELECT * FROM report_deliveries WHERE status='active' ORDER BY created_at").all() as any[];
8
+ const due = deliveries.filter((delivery) => isDeliveryDue(delivery));
9
+ const results = [];
10
+ for (const delivery of due) results.push(await deliverReport(delivery));
11
+ return json({ checked: deliveries.length, due: due.length, results });
12
+ };
@@ -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 { deliverReport } from "../../../../lib/report-delivery";
5
+
6
+ export const POST: APIRoute = async ({ request }) => { const body = await request.json().catch(() => null) as any; const delivery = db().prepare("SELECT * FROM report_deliveries WHERE id=? AND status='active'").get(body?.deliveryId) as any; if (!delivery) return json({ error: "active delivery not found" }, 404); const result = await deliverReport(delivery); return json(result, result.status === "failed" ? 502 : 200); };
@@ -0,0 +1,17 @@
1
+ import type { APIRoute } from "astro";
2
+ import { db } from "../../../../lib/db";
3
+ import { json } from "../../../../lib/ops-auth";
4
+ import { createReportPdf } from "../../../../lib/report-pdf";
5
+
6
+ const escape = (value: unknown) => String(value ?? "").replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[char] || char));
7
+ export const GET: APIRoute = ({ url }) => {
8
+ const reportId = url.searchParams.get("reportId"); const format = url.searchParams.get("format") || "json";
9
+ const report = reportId ? db().prepare("SELECT * FROM blog_reports WHERE id=?").get(reportId) as any : db().prepare("SELECT * FROM blog_reports ORDER BY COALESCE(completed_at,started_at) DESC LIMIT 1").get() as any;
10
+ if (!report) return json({ error: "report not found" }, 404);
11
+ const summary = JSON.parse(report.summary_json || "{}"); const findings = JSON.parse(report.findings_json || "[]"); const workspace = url.searchParams.get("workspaceId") ? db().prepare("SELECT name,logo_url,brand_colors_json FROM workspaces WHERE id=?").get(url.searchParams.get("workspaceId")) as any : null;
12
+ if (format === "json") return json({ report: { ...report, summary, findings } });
13
+ if (format === "csv") { const rows: string[][] = [["severity", "code", "url", "message"], ...findings.map((item: any) => [String(item.severity || ""), String(item.code || ""), String(item.url || ""), String(item.message || "")])]; const csv = rows.map((row) => row.map((value: string) => `"${value.replaceAll('"', '""')}"`).join(",")).join("\n"); return new Response(csv + "\n", { headers: { "content-type": "text/csv; charset=utf-8", "content-disposition": `attachment; filename="${report.run_type}.csv"` } }); }
14
+ if (format === "pdf") return new Response(createReportPdf(`${workspace?.name || "Maggie"} ${report.run_type} report`, summary, findings), { headers: { "content-type": "application/pdf", "content-disposition": `attachment; filename="${report.run_type}.pdf"` } });
15
+ if (format === "html") { const body = `${workspace?.logo_url ? `<img alt="${escape(workspace.name)}" src="${escape(workspace.logo_url)}" style="max-height:48px">` : ""}<h1>${escape(workspace?.name || "")} ${escape(report.run_type)} report</h1><p>${escape(JSON.stringify(summary))}</p><table><thead><tr><th>Severity</th><th>Code</th><th>Message</th></tr></thead><tbody>${findings.map((item: any) => `<tr><td>${escape(item.severity)}</td><td>${escape(item.code)}</td><td>${escape(item.message)}</td></tr>`).join("")}</tbody></table>`; return new Response(`<!doctype html><html><head><meta charset="utf-8"><title>${escape(report.run_type)}</title><style>body{font:14px system-ui;margin:40px}table{border-collapse:collapse}td,th{border:1px solid #ddd;padding:8px}</style></head><body>${body}</body></html>`, { headers: { "content-type": "text/html; charset=utf-8" } }); }
16
+ return json({ error: "format must be json, csv, html, or pdf" }, 400);
17
+ };
@@ -0,0 +1,31 @@
1
+ import type { APIRoute } from "astro";
2
+ import { db, id, now } from "../../../lib/db";
3
+ import { json } from "../../../lib/ops-auth";
4
+ import { runProviderReport } from "../../../lib/google";
5
+
6
+ export const GET: APIRoute = () => json({ items: db().prepare("SELECT * FROM blog_reports ORDER BY COALESCE(completed_at,started_at) DESC LIMIT 50").all() });
7
+ export const POST: APIRoute = async ({ request }) => {
8
+ const body = await request.json().catch(() => null) as any;
9
+ const runType = body?.runType || "technical_seo"; const reportId = id("report"); const timestamp = now();
10
+ const findings: Array<{ severity: string; code: string; url?: string; message: string }> = [];
11
+ const database = db();
12
+ if (runType === "technical_seo" || runType === "content_quality") {
13
+ const posts = database.prepare("SELECT slug,title,excerpt,canonical_url,content_html,cover_image_alt,author_id,published_at FROM posts WHERE status='published' AND published_at IS NOT NULL").all() as any[];
14
+ for (const post of posts) {
15
+ const url = `/blog/${post.slug}`;
16
+ if (!post.title) findings.push({ severity: "error", code: "missing_title", url, message: "Published post has no title." });
17
+ if (!post.excerpt) findings.push({ severity: "warning", code: "missing_description", url, message: "Published post has no excerpt/description." });
18
+ if (!post.canonical_url || !/^https?:\/\//.test(post.canonical_url)) findings.push({ severity: "error", code: "invalid_canonical", url, message: "Published post has no absolute canonical URL." });
19
+ if (!post.author_id) findings.push({ severity: "error", code: "missing_author", url, message: "Published post has no author." });
20
+ if (runType === "content_quality" && !post.content_html) findings.push({ severity: "error", code: "missing_content", url, message: "Published post has no rendered content." });
21
+ if (runType === "technical_seo" && post.cover_image_alt === "") findings.push({ severity: "warning", code: "missing_image_alt", url, message: "Cover image alt text is empty." });
22
+ }
23
+ const summary = { runType, checkedPosts: posts.length, errors: findings.filter((item) => item.severity === "error").length, warnings: findings.filter((item) => item.severity === "warning").length };
24
+ database.prepare("INSERT INTO blog_reports (id,run_type,status,summary_json,findings_json,started_at,completed_at) VALUES (?,?,?,?,?,?,?)").run(reportId, runType, "completed", JSON.stringify(summary), JSON.stringify(findings), timestamp, now());
25
+ return json({ id: reportId, status: "completed", summary, findings });
26
+ }
27
+ const error = `${runType} report requires a configured provider adapter`;
28
+ if (runType !== "visibility" && runType !== "analytics") { database.prepare("INSERT INTO blog_reports (id,run_type,status,summary_json,findings_json,started_at,completed_at,error) VALUES (?,?,?,?,?,?,?,?)").run(reportId, runType, "failed", "{}", JSON.stringify([{ severity: "error", code: "provider_not_configured", message: error }]), timestamp, now(), error); return json({ id: reportId, status: "failed", error }, 503); }
29
+ const endDate = body?.endDate || new Date().toISOString().slice(0, 10); const startDate = body?.startDate || new Date(Date.now() - 28 * 86400000).toISOString().slice(0, 10);
30
+ try { const result = await runProviderReport(runType, startDate, endDate); const summary = { runType, provider: result.provider, startDate, endDate, data: result.data }; database.prepare("INSERT INTO blog_reports (id,run_type,status,summary_json,findings_json,started_at,completed_at) VALUES (?,?,?,?,?,?,?)").run(reportId, runType, "completed", JSON.stringify(summary).slice(0, 500000), "[]", timestamp, now()); return json({ id: reportId, status: "completed", summary }); } catch (providerError) { const message = providerError instanceof Error ? providerError.message : error; database.prepare("INSERT INTO blog_reports (id,run_type,status,summary_json,findings_json,started_at,completed_at,error) VALUES (?,?,?,?,?,?,?,?)").run(reportId, runType, "failed", "{}", JSON.stringify([{ severity: "error", code: "provider_error", message }]), timestamp, now(), message); return json({ id: reportId, status: "failed", error: message }, 502); }
31
+ };
@@ -0,0 +1,3 @@
1
+ import type { APIRoute } from "astro";
2
+ import { aiCmoRequest } from "../../../../../lib/ai-cmo";
3
+ export const GET: APIRoute = ({ params }) => aiCmoRequest(`/rewrite/history/${encodeURIComponent(params.contentId || "")}`, "GET");
@@ -0,0 +1,4 @@
1
+ import type { APIRoute } from "astro";
2
+ import { aiCmoRequest } from "../../../../lib/ai-cmo";
3
+ export const GET: APIRoute = () => aiCmoRequest("/rewrite-policy", "GET");
4
+ export const PATCH: APIRoute = async ({ request }) => aiCmoRequest("/rewrite-policy", "POST", await request.json().catch(() => null));
@@ -0,0 +1,4 @@
1
+ import type { APIRoute } from "astro";
2
+ import { aiCmoRequest } from "../../../../lib/ai-cmo";
3
+ export const GET: APIRoute = () => aiCmoRequest("/rewrite/queue", "GET");
4
+ export const POST: APIRoute = async ({ request }) => aiCmoRequest("/rewrite/queue", "POST", await request.json().catch(() => null));
@@ -0,0 +1,7 @@
1
+ import type { APIRoute } from "astro";
2
+ import { db } from "../../../../lib/db";
3
+ import { json } from "../../../../lib/ops-auth";
4
+ import { runTechnicalAudit } from "../../../../lib/seo-audit";
5
+
6
+ export const GET: APIRoute = ({ url }) => { const runId = url.searchParams.get("runId"); if (runId) return json({ run: db().prepare("SELECT * FROM blog_reports WHERE id=?").get(runId), findings: db().prepare("SELECT * FROM seo_findings WHERE run_id=? ORDER BY severity DESC,category").all(runId) }); return json({ items: db().prepare("SELECT id,run_type,status,summary_json,started_at,completed_at FROM blog_reports ORDER BY COALESCE(completed_at,started_at) DESC LIMIT 20").all() }); };
7
+ export const POST: APIRoute = () => json(runTechnicalAudit(), 201);
@@ -0,0 +1,5 @@
1
+ import type { APIRoute } from "astro";
2
+ import { json } from "../../../../lib/ops-auth";
3
+ import { seoInsights } from "../../../../lib/seo-insights";
4
+
5
+ export const GET: APIRoute = () => json(seoInsights());
@@ -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
+ const allowed = new Set(["siteName", "postsPerPage", "gridColumns", "defaultAuthorId", "defaultCtaId", "locale", "timezone"]);
6
+ export const GET: APIRoute = () => json({ items: db().prepare("SELECT key,value_json,updated_at FROM blog_settings WHERE key IN ('siteName','postsPerPage','gridColumns','defaultAuthorId','defaultCtaId','locale','timezone') ORDER BY key").all() });
7
+ export const PATCH: APIRoute = async ({ request }) => { const body = await request.json().catch(() => null) as Record<string, unknown> | null; if (!body) return json({ error: "JSON object required" }, 400); const entries = Object.entries(body); if (!entries.length || entries.some(([key]) => !allowed.has(key))) return json({ error: "Only documented site settings may be changed" }, 400); const statement = db().prepare("INSERT INTO blog_settings (key,value_json,updated_at) VALUES (?,?,?) ON CONFLICT(key) DO UPDATE SET value_json=excluded.value_json, updated_at=excluded.updated_at"); const transaction = db().transaction(() => entries.forEach(([key, value]) => { statement.run(key, JSON.stringify(value), now()); audit("site_setting.updated", "setting", key, null, value); })); transaction(); return json({ ok: true }); };
@@ -0,0 +1,19 @@
1
+ import type { APIRoute } from "astro";
2
+ import { audit, db, now, settingBoolean } from "../../../lib/db";
3
+ import { json } from "../../../lib/ops-auth";
4
+ import { providerConfigured } from "../../../lib/google";
5
+
6
+ export const GET: APIRoute = () => {
7
+ const settings = db().prepare("SELECT key,value_json,updated_at FROM blog_settings ORDER BY key").all() as any[];
8
+ const safe = settings.filter((item) => !/(key|token|secret|password|credential)/i.test(item.key)).map((item) => ({ ...item, value: JSON.parse(item.value_json) }));
9
+ return json({ items: safe, integrations: { aiCmo: !settingBoolean("aiCmoEnabled") ? "disabled" : (import.meta.env.AI_CMO_API_KEY ? "configured" : "disabled"), gsc: !settingBoolean("gscEnabled") ? "disabled" : (providerConfigured("visibility") ? "verified" : (import.meta.env.GSC_VERIFICATION_TOKEN ? "configured" : "disabled")), ga4: !settingBoolean("ga4Enabled") ? "disabled" : (providerConfigured("analytics") ? "verified" : (import.meta.env.GA4_MEASUREMENT_ID ? "configured" : "disabled")) } });
10
+ };
11
+ export const PATCH: APIRoute = async ({ request }) => {
12
+ const body = await request.json().catch(() => null) as Record<string, unknown> | null;
13
+ if (!body || typeof body !== "object") return json({ error: "JSON object required" }, 400);
14
+ const entries = Object.entries(body);
15
+ if (entries.some(([key]) => /(key|token|secret|password|credential)/i.test(key))) return json({ error: "Secret values must be configured through server environment variables" }, 400);
16
+ const statement = db().prepare("INSERT INTO blog_settings (key,value_json,updated_at) VALUES (?,?,?) ON CONFLICT(key) DO UPDATE SET value_json=excluded.value_json, updated_at=excluded.updated_at");
17
+ const transaction = db().transaction(() => entries.forEach(([key, value]) => { statement.run(key, JSON.stringify(value), now()); audit("setting.updated", "setting", key, null, value); }));
18
+ transaction(); return json({ ok: true });
19
+ };
@@ -0,0 +1,13 @@
1
+ import type { APIRoute } from "astro";
2
+ import { aiCmoRequest } from "../../../../lib/ai-cmo";
3
+ import { db, id, now } from "../../../../lib/db";
4
+
5
+ export const POST: APIRoute = async ({ request }) => {
6
+ const body = await request.json().catch(() => ({}));
7
+ const response = await aiCmoRequest("/sitemap/auto-detect", "POST", body);
8
+ const text = await response.text(); let result: any = {}; try { result = text ? JSON.parse(text) : {}; } catch { result = { raw: text }; }
9
+ const candidates = Array.isArray(result.sitemaps) ? result.sitemaps : Array.isArray(result.urls) ? result.urls : [];
10
+ const timestamp = now();
11
+ for (const candidate of candidates) { const url = typeof candidate === "string" ? candidate : candidate?.url; if (!url) continue; db().prepare("INSERT INTO sitemap_sources (id,url,source_type,status,created_at,updated_at) VALUES (?,?,?,?,?,?) ON CONFLICT(url) DO UPDATE SET updated_at=excluded.updated_at, status='active'").run(id("sitemap"), url, "auto_detected", "active", timestamp, timestamp); }
12
+ return new Response(JSON.stringify(result), { status: response.status, headers: { "content-type": "application/json" } });
13
+ };
@@ -0,0 +1,11 @@
1
+ import type { APIRoute } from "astro";
2
+ import { createHash } from "node:crypto";
3
+ import { db, id, now } from "../../../../lib/db";
4
+ import { json } from "../../../../lib/ops-auth";
5
+
6
+ const urlsFrom = (xml: string) => [...xml.matchAll(/<loc>([\s\S]*?)<\/loc>/gi)].map((match) => match[1].trim()).filter(Boolean);
7
+ export const POST: APIRoute = async ({ request }) => {
8
+ const body = await request.json().catch(() => null) as any; const sourceUrl = body?.sourceUrl; if (!sourceUrl) return json({ error: "sourceUrl is required" }, 400); let urls = Array.isArray(body.urls) ? body.urls.map(String) : [];
9
+ if (!urls.length) { try { const response = await fetch(sourceUrl, { signal: AbortSignal.timeout(30000) }); if (!response.ok) return json({ error: `sitemap returned ${response.status}` }, 502); urls = urlsFrom(await response.text()); } catch (error) { return json({ error: error instanceof Error ? error.message : "sitemap fetch failed" }, 502); } }
10
+ urls = [...new Set(urls)].sort(); const serialized = JSON.stringify(urls); const digest = createHash("sha256").update(serialized).digest("hex"); const previous = db().prepare("SELECT urls_json,checksum,created_at FROM sitemap_snapshots WHERE source_url=? ORDER BY created_at DESC LIMIT 1").get(sourceUrl) as any; const before = previous ? new Set<string>(JSON.parse(previous.urls_json)) : new Set<string>(); const after = new Set<string>(urls); const added = urls.filter((url: string) => !before.has(url)); const removed = [...before].filter((url: string) => !after.has(url)); const timestamp = now(); db().prepare("INSERT INTO sitemap_snapshots (id,source_url,urls_json,checksum,created_at) VALUES (?,?,?,?,?)").run(id("sitemap_snapshot"), sourceUrl, serialized, digest, timestamp); return json({ sourceUrl, total: urls.length, checksum: digest, changed: !previous || previous.checksum !== digest, added, removed, previousAt: previous?.created_at || null });
11
+ };
@@ -0,0 +1,16 @@
1
+ import type { APIRoute } from "astro";
2
+ import { aiCmoRequest } from "../../../../lib/ai-cmo";
3
+ import { db, id, 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 any;
8
+ if (!body?.sitemapUrl) return json({ error: "sitemapUrl is required" }, 400);
9
+ const response = await aiCmoRequest("/sitemap/match", "POST", { sitemap_url: body.sitemapUrl });
10
+ const text = await response.text(); let result: any = {}; try { result = text ? JSON.parse(text) : {}; } catch { result = { raw: text }; }
11
+ const timestamp = now(); const sourceId = id("sitemap");
12
+ db().prepare("INSERT INTO sitemap_sources (id,url,source_type,status,created_at,updated_at) VALUES (?,?,?,?,?,?) ON CONFLICT(url) DO UPDATE SET updated_at=excluded.updated_at, status='active'").run(sourceId, body.sitemapUrl, "manual", "active", timestamp, timestamp);
13
+ const storedSource = db().prepare("SELECT id FROM sitemap_sources WHERE url=?").get(body.sitemapUrl) as { id: string };
14
+ db().prepare("INSERT INTO sitemap_runs (id,source_id,status,total_urls,matched_posts,eligible_posts,unmatched_urls,error,started_at,completed_at) VALUES (?,?,?,?,?,?,?,?,?,?)").run(id("sitemap_run"), storedSource.id, response.ok ? "completed" : "failed", Number(result.total_urls ?? result.total ?? 0), Number(result.matched_posts ?? result.matched ?? result.matched_count ?? 0), Number(result.eligible_posts ?? result.eligible ?? 0), Number(result.unmatched_urls ?? result.unmatched ?? 0), response.ok ? null : (result.error || text).slice(0, 1000), timestamp, timestamp);
15
+ return new Response(JSON.stringify(result), { status: response.status, headers: { "content-type": "application/json" } });
16
+ };
@@ -0,0 +1,4 @@
1
+ import type { APIRoute } from "astro";
2
+ import { aiCmoRequest } from "../../../../lib/ai-cmo";
3
+
4
+ export const GET: APIRoute = () => aiCmoRequest("/sitemap/matching-history", "GET");
@@ -0,0 +1,4 @@
1
+ import type { APIRoute } from "astro";
2
+ import { db } from "../../../../lib/db";
3
+ import { json } from "../../../../lib/ops-auth";
4
+ export const GET: APIRoute = ({ url }) => { const limit = Math.min(Number(url.searchParams.get("limit") || 20), 100); return json({ items: db().prepare("SELECT r.*, s.url sitemap_url FROM sitemap_runs r LEFT JOIN sitemap_sources s ON s.id=r.source_id ORDER BY r.started_at DESC LIMIT ?").all(limit) }); };
@@ -0,0 +1,11 @@
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({ sources: db().prepare("SELECT * FROM sitemap_sources ORDER BY updated_at DESC").all(), runs: db().prepare("SELECT * FROM sitemap_runs ORDER BY started_at DESC LIMIT 20").all() });
6
+ export const POST: APIRoute = async ({ request }) => {
7
+ const body = await request.json().catch(() => null) as any;
8
+ if (!body?.url) return json({ error: "url is required" }, 400);
9
+ const timestamp = now();
10
+ try { const sourceId = id("sitemap"); db().prepare("INSERT INTO sitemap_sources (id,url,source_type,status,created_at,updated_at) VALUES (?,?,?,?,?,?) ON CONFLICT(url) DO UPDATE SET updated_at=excluded.updated_at, status='active'").run(sourceId, body.url, body.sourceType || "manual", "active", timestamp, timestamp); return json({ ok: true, url: body.url }); } catch (error) { return json({ error: error instanceof Error ? error.message : "sitemap could not be saved" }, 409); }
11
+ };
@@ -0,0 +1,12 @@
1
+ import type { APIRoute } from "astro";
2
+ import { db } from "../../../lib/db";
3
+ import { json } from "../../../lib/ops-auth";
4
+ import { providerConfigured } from "../../../lib/google";
5
+
6
+ export const GET: APIRoute = () => {
7
+ const database = db();
8
+ const count = (status: string) => (database.prepare("SELECT COUNT(*) AS count FROM posts WHERE status=?").get(status) as { count: number }).count;
9
+ const lastSitemap = database.prepare("SELECT started_at,matched_posts,eligible_posts,unmatched_urls,status FROM sitemap_runs ORDER BY started_at DESC LIMIT 1").get() as any;
10
+ const openErrors = (database.prepare("SELECT COUNT(*) AS count FROM blog_reports WHERE status='failed'").get() as { count: number }).count;
11
+ return json({ bootstrap: "incomplete", content: { published: count("published"), drafts: count("draft"), review: count("review"), failedSyncs: (database.prepare("SELECT COUNT(*) AS count FROM content_sync_state WHERE last_error IS NOT NULL").get() as { count: number }).count }, sitemap: { lastRunAt: lastSitemap?.started_at, status: lastSitemap?.status, matched: lastSitemap?.matched_posts || 0, eligible: lastSitemap?.eligible_posts || 0, unmatched: lastSitemap?.unmatched_urls || 0 }, reports: { openErrors, lastTechnicalSeoRunAt: (database.prepare("SELECT completed_at FROM blog_reports WHERE run_type='technical_seo' ORDER BY completed_at DESC LIMIT 1").get() as { completed_at?: string } | undefined)?.completed_at }, integrations: { aiCmo: import.meta.env.AI_CMO_API_KEY ? "configured" : "disabled", gsc: providerConfigured("visibility") ? "verified" : "disabled", ga4: providerConfigured("analytics") ? "verified" : "disabled" }, generatedAt: new Date().toISOString() });
12
+ };
@@ -0,0 +1,12 @@
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 POST: APIRoute = async ({ params, request }) => {
6
+ const body = await request.json().catch(() => null) as any;
7
+ if (!body?.question || !body?.answer) return json({ error: "question and answer are required" }, 400);
8
+ if (!db().prepare("SELECT 1 FROM topics WHERE id=?").get(params.id)) return json({ error: "Topic not found" }, 404);
9
+ const faqId = id("faq"); const timestamp = now();
10
+ const transaction = db().transaction(() => { db().prepare("INSERT INTO faq_items (id,question,answer_markdown,sort_order,status,created_at,updated_at) VALUES (?,?,?,?,?,?,?)").run(faqId, body.question, body.answer, Number(body.sortOrder || 0), body.status || "draft", timestamp, timestamp); db().prepare("INSERT INTO topic_faqs (topic_id,faq_id) VALUES (?,?)").run(params.id, faqId); });
11
+ try { transaction(); audit("faq.created", "topic", params.id!, null, { faqId, question: body.question }); return json({ id: faqId }, 201); } catch (error) { return json({ error: error instanceof Error ? error.message : "FAQ could not be created" }, 409); }
12
+ };
@@ -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 t.*, COUNT(pt.post_id) AS post_count FROM topics t LEFT JOIN post_topics pt ON pt.topic_id=t.id GROUP BY t.id ORDER BY t.title").all() });
6
+ export const POST: APIRoute = async ({ request }) => {
7
+ const body = await request.json().catch(() => null) as any;
8
+ if (!body?.slug || !body?.title || !body?.description) return json({ error: "slug, title and description are required" }, 400);
9
+ const topicId = id("topic"); const timestamp = now();
10
+ try { db().prepare("INSERT INTO topics (id,slug,title,description,status,created_at,updated_at) VALUES (?,?,?,?,?,?,?)").run(topicId, body.slug, body.title, body.description, body.status || "draft", timestamp, timestamp); audit("topic.created", "topic", topicId, null, body); return json({ id: topicId }, 201); } catch (error) { return json({ error: error instanceof Error ? error.message : "topic could not be created" }, 409); }
11
+ };
@@ -0,0 +1,5 @@
1
+ import type { APIRoute } from "astro";
2
+ import { inspectWordPressCapabilities } from "../../../../lib/wordpress";
3
+ import { json } from "../../../../lib/ops-auth";
4
+
5
+ export const GET: APIRoute = async ({ url }) => { const sourceUrl = url.searchParams.get("sourceUrl"); if (!sourceUrl) return json({ error: "sourceUrl is required" }, 400); try { return json(await inspectWordPressCapabilities(sourceUrl)); } catch (error) { return json({ error: error instanceof Error ? error.message : "capability detection failed" }, 502); } };
@@ -0,0 +1,21 @@
1
+ import type { APIRoute } from "astro";
2
+ import { id, now, db } from "../../../../lib/db";
3
+ import { inspectWordPress, inspectWordPressCapabilities } from "../../../../lib/wordpress";
4
+ import { json } from "../../../../lib/ops-auth";
5
+
6
+ export const POST: APIRoute = async ({ request }) => {
7
+ const body = await request.json().catch(() => null) as any;
8
+ if (!body?.sourceUrl) return json({ error: "sourceUrl is required" }, 400);
9
+ const postTypes = Array.isArray(body.postTypes) && body.postTypes.length ? body.postTypes.map(String) : ["post"];
10
+ const selections = {
11
+ postTypes,
12
+ resources: { posts: body.resources?.posts !== false, pages: body.resources?.pages === true, categories: body.resources?.categories !== false, tags: body.resources?.tags !== false, media: body.resources?.media !== false, authors: body.resources?.authors !== false },
13
+ mapping: { categoryToTopic: body.mapping?.categoryToTopic || {}, tagToTaxonomy: body.mapping?.tagToTaxonomy || "tag", authorToAuthor: body.mapping?.authorToAuthor || {}, featuredImageToMedia: body.mapping?.featuredImageToMedia !== false, seo: body.mapping?.seo || { yoast: true, rankMath: true, aioseo: true, customFields: true } },
14
+ };
15
+ try {
16
+ const [capabilities, preview] = await Promise.all([inspectWordPressCapabilities(String(body.sourceUrl)), inspectWordPress(String(body.sourceUrl), { maxPosts: Number(body.maxPosts || 100), maxTerms: Number(body.maxTerms || 1000), mediaMode: selections.resources.media ? "all" : "featured" })]);
17
+ const planId = id("wp_plan"); const timestamp = now();
18
+ db().prepare("INSERT INTO wp_migrations (id,source_url,mode,status,options_json,counts_json,started_at,completed_at) VALUES (?,?,?,?,?,?,?,?)").run(planId, String(body.sourceUrl), "preview", "completed", JSON.stringify(selections), JSON.stringify(preview.totals), timestamp, timestamp);
19
+ return json({ id: planId, status: "planned", capabilities, selections, counts: preview.totals, urlMappingPreview: preview.sample.map((post) => ({ source: post.slug, destination: `/blog/${post.slug}`, title: post.title })) });
20
+ } catch (error) { return json({ error: error instanceof Error ? error.message : "migration plan failed" }, 502); }
21
+ };
@@ -0,0 +1,5 @@
1
+ import type { APIRoute } from "astro";
2
+ import { db, now } from "../../../../../../lib/db";
3
+ import { json } from "../../../../../../lib/ops-auth";
4
+ import { importWordPress } from "../../../../../../lib/wordpress";
5
+ export const POST: APIRoute = async ({ 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); if (migration.status === "running") return json({ error: "Migration is already running" }, 409); const options = JSON.parse(migration.options_json); db().prepare("UPDATE wp_migrations SET mode='apply',status='running',error=NULL,completed_at=NULL WHERE id=?").run(params.id); try { const counts = await importWordPress(params.id!, migration.source_url, options); db().prepare("UPDATE wp_migrations SET status='completed',counts_json=?,completed_at=? WHERE id=?").run(JSON.stringify(counts), now(), params.id); return json({ id: params.id, status: "completed", counts }); } catch (error) { const message = error instanceof Error ? error.message : "WordPress migration failed"; db().prepare("UPDATE wp_migrations SET status='failed',error=?,completed_at=? WHERE id=?").run(message, now(), params.id); return json({ id: params.id, status: "failed", error: message }, 502); } };
@@ -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,13 @@
1
+ import type { APIRoute } from "astro";
2
+ import { audit, db, now } from "../../../../../lib/db";
3
+ import { json } from "../../../../../lib/ops-auth";
4
+ 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), items: db().prepare("SELECT entity_type,source_id,target_id,action,error,created_at FROM wp_import_items WHERE migration_id=? ORDER BY entity_type,source_id").all(params.id) }); };
5
+ function restoreMigrationRevision(postId: string, startedAt: string): boolean { const revision = db().prepare("SELECT snapshot_json FROM post_revisions WHERE post_id=? AND reason='WordPress 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; }
6
+ export const POST: APIRoute = async ({ params, request }) => {
7
+ const body = await request.json().catch(() => null) as any; if (body?.action !== "rollback") return json({ error: "action must be rollback" }, 400);
8
+ const migration = db().prepare("SELECT * FROM wp_migrations WHERE id=?").get(params.id) as any; if (!migration) return json({ error: "Migration not found" }, 404);
9
+ 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 }>;
10
+ 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 }>;
11
+ 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); const restored = updated.filter((item) => restoreMigrationRevision(item.target_id, migration.started_at)).length; db().prepare("UPDATE wp_migrations SET status='cancelled',completed_at=?,error=? WHERE id=?").run(now(), "rolled back by operator", params.id); return restored; }); const restored = tx();
12
+ audit("wordpress.migration.rolled_back", "wordpress_migration", params.id!, { status: migration.status }, { removedPosts: items.length, restoredUpdatedPosts: restored }); return json({ id: params.id, status: "cancelled", removedPosts: items.length, restoredUpdatedPosts: restored });
13
+ };