Stage 5: admin dashboard (Next.js App Router) + completes Stage 2.3 handlers
Operator console for the Publishing Engine — cool/forge Nine Laws temperature (forest + citrus, amber deliberately absent). - Auth: Authentik forward-auth header parsing (x-authentik-username / -groups -> roles) with a dev-stub principal (AUTH_DEV_PRINCIPAL). Pure auth-core is unit-tested; getPrincipal binds it to next/headers. - API route handlers (closes Stage 2.3): /api/content, /api/approvals, /api/publications/cancel, /api/feature-flags. Each: Zod parse -> principal -> role authz (requireAllowed) -> DB write + append-only audit. handleMutation maps errors to 401/403/404/409/422/500 with a correlation id. - Pages (force-dynamic, DB-backed): /queue (approve/cancel), /publications/[id] (detail + metrics), /metrics (AST day x hour heatmap with text+color per WCAG 1.4.1), /auth/linkedin (OAuth status + per-outlet kill-switch toggle). - A11y: semantic landmarks, skip-link, scoped table headers, aria-live error alerts, heatmap text redundancy. - Tests: 10 pure (auth-core, heatmap) + 5 gated handler-integration (authz tiers + happy paths vs real Postgres). Strict tsc + next build (standalone) green. Deferred (need a browser/IdP runner): live Authentik binding, axe-core/Playwright + NVDA CI, Lighthouse gates (Stage 5.3/5.4). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const config: NextConfig = {
|
||||
output: "standalone",
|
||||
reactStrictMode: true,
|
||||
// The admin app is server-rendered (DB-backed, auth-gated); never statically exported.
|
||||
transpilePackages: ["@stargue/schema", "@stargue/sanitize", "@stargue/observability"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -15,17 +15,22 @@
|
||||
"@stargue/schema": "workspace:*",
|
||||
"@stargue/sanitize": "workspace:*",
|
||||
"@stargue/observability": "workspace:*",
|
||||
"drizzle-orm": "^0.36.0",
|
||||
"next": "^16.0.0",
|
||||
"postgres": "^3.4.5",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"server-only": "^0.0.1",
|
||||
"zod": "^3.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.10.0",
|
||||
"@playwright/test": "^1.48.0",
|
||||
"@tailwindcss/postcss": "^4.2.2",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"axe-core": "^4.10.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^2.1.0"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { handleMutation } from "@/lib/api";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { createApproval } from "@/lib/handlers";
|
||||
import { CreateApprovalRequestSchema } from "@stargue/schema";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
return handleMutation(req, CreateApprovalRequestSchema, (input, principal, corr) =>
|
||||
createApproval(getDb(), principal, input, corr),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { handleMutation } from "@/lib/api";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { createContent } from "@/lib/handlers";
|
||||
import { CreateContentRequestSchema } from "@stargue/schema";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
return handleMutation(req, CreateContentRequestSchema, (input, principal, corr) =>
|
||||
createContent(getDb(), principal, input, corr),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { handleMutation } from "@/lib/api";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { toggleFeatureFlag } from "@/lib/handlers";
|
||||
import { ToggleFeatureFlagRequestSchema } from "@stargue/schema";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
return handleMutation(req, ToggleFeatureFlagRequestSchema, (input, principal, corr) =>
|
||||
toggleFeatureFlag(getDb(), principal, input, corr),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { handleMutation } from "@/lib/api";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { cancelPublication } from "@/lib/handlers";
|
||||
import { CancelPublicationRequestSchema } from "@stargue/schema";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
return handleMutation(req, CancelPublicationRequestSchema, (input, principal, corr) =>
|
||||
cancelPublication(getDb(), principal, input, corr),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { getDb } from "@/lib/db";
|
||||
import { listFeatureFlags } from "@/lib/handlers";
|
||||
import { getPrincipal } from "@/lib/auth";
|
||||
import { FlagToggle } from "@/components/FlagToggle";
|
||||
import { linkedin_tokens } from "@stargue/schema/db";
|
||||
import { fmtAst } from "@/lib/format";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function LinkedInAuthPage() {
|
||||
const principal = await getPrincipal();
|
||||
if (!principal) {
|
||||
return (
|
||||
<p className="text-[var(--color-muted)]">Not authenticated. Sign in via Authentik.</p>
|
||||
);
|
||||
}
|
||||
const db = getDb();
|
||||
const flags = await listFeatureFlags(db);
|
||||
const tokens = await db
|
||||
.select({
|
||||
subject_type: linkedin_tokens.subject_type,
|
||||
subject_urn: linkedin_tokens.subject_urn,
|
||||
access_expires_at: linkedin_tokens.access_expires_at,
|
||||
refresh_expires_at: linkedin_tokens.refresh_expires_at,
|
||||
scopes: linkedin_tokens.scopes,
|
||||
})
|
||||
.from(linkedin_tokens);
|
||||
const canToggle = principal.roles.includes("operator");
|
||||
|
||||
return (
|
||||
<section className="space-y-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">LinkedIn connection</h1>
|
||||
<p className="mt-1 text-sm text-[var(--color-muted)]">
|
||||
OAuth status and per-outlet kill-switch.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-6">
|
||||
<h2 className="text-lg font-semibold">Authorization</h2>
|
||||
{tokens.length === 0 ? (
|
||||
<p className="mt-2 text-sm text-[var(--color-muted)]">
|
||||
Not connected. Community Management API access is in review; once granted,
|
||||
authorize the app here to begin publishing.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-3 space-y-2 text-sm">
|
||||
{tokens.map((t) => (
|
||||
<li key={t.subject_urn} className="flex flex-wrap items-center gap-x-4 gap-y-1">
|
||||
<span className="font-mono text-xs">{t.subject_urn}</span>
|
||||
<span className="text-[var(--color-muted)]">({t.subject_type})</span>
|
||||
<span className="text-[var(--color-muted)]">
|
||||
access expires {fmtAst(t.access_expires_at)}
|
||||
</span>
|
||||
<span className="text-[var(--color-muted)]">
|
||||
refresh expires {fmtAst(t.refresh_expires_at)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-6">
|
||||
<h2 className="text-lg font-semibold">Kill-switch</h2>
|
||||
<p className="mt-1 text-sm text-[var(--color-muted)]">
|
||||
Disable an outlet to halt dispatch within ~30s without a redeploy. Toggling is
|
||||
audit-logged.
|
||||
</p>
|
||||
{flags.length === 0 ? (
|
||||
<p className="mt-3 text-sm text-[var(--color-muted)]">
|
||||
No outlet flags set yet (outlets default to enabled).
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-4 space-y-3">
|
||||
{flags.map((f) => (
|
||||
<li key={f.outlet} className="flex flex-wrap items-center gap-3">
|
||||
<span className="w-24 font-medium">{f.outlet}</span>
|
||||
<span className="text-sm text-[var(--color-muted)]">
|
||||
{f.enabled ? "enabled" : `disabled — ${f.reason ?? "no reason"}`}
|
||||
</span>
|
||||
{canToggle && <FlagToggle outlet={f.outlet} enabled={f.enabled} />}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/*
|
||||
* Nine Laws of the Hearth — admin/operator temperature (cool / forge).
|
||||
* Forest (#4E8211) + Citrus (#99CC00) on near-black green. Amber is reserved
|
||||
* for Hearth content surfaces and never appears here (Law 4).
|
||||
*/
|
||||
@theme {
|
||||
--color-bg: #0e1303;
|
||||
--color-surface: #18200a;
|
||||
--color-surface-2: #212b0f;
|
||||
--color-border: #34401b;
|
||||
--color-forest: #4e8211;
|
||||
--color-citrus: #99cc00;
|
||||
--color-ink: #e9ede0;
|
||||
--color-muted: #9aa089;
|
||||
--color-danger: #ef4444;
|
||||
--color-slate: #64748b;
|
||||
--font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-citrus);
|
||||
}
|
||||
a:hover {
|
||||
color: var(--color-forest);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import "./globals.css";
|
||||
import type { Metadata } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Stargue Publishing Engine — Admin",
|
||||
description: "Operator console for the Stargue Publishing Engine.",
|
||||
robots: "noindex, nofollow",
|
||||
};
|
||||
|
||||
const NAV = [
|
||||
{ href: "/queue", label: "Queue" },
|
||||
{ href: "/metrics", label: "Metrics" },
|
||||
{ href: "/auth/linkedin", label: "LinkedIn" },
|
||||
];
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className="min-h-screen antialiased">
|
||||
<a
|
||||
href="#main"
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50 focus:rounded focus:bg-[var(--color-forest)] focus:px-3 focus:py-2 focus:text-white"
|
||||
>
|
||||
Skip to content
|
||||
</a>
|
||||
|
||||
<header className="border-b border-[var(--color-border)] bg-[var(--color-surface)]">
|
||||
<nav
|
||||
aria-label="Primary"
|
||||
className="mx-auto flex max-w-6xl items-center justify-between px-6 py-3"
|
||||
>
|
||||
<Link href="/queue" className="flex items-center gap-2 !text-[var(--color-ink)]">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-block h-3 w-3 rounded-full"
|
||||
style={{ backgroundColor: "var(--color-citrus)" }}
|
||||
/>
|
||||
<span className="font-semibold tracking-tight">Stargue PE</span>
|
||||
<span className="text-xs text-[var(--color-muted)]">· operator</span>
|
||||
</Link>
|
||||
<ul className="flex items-center gap-1 text-sm">
|
||||
{NAV.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
href={item.href}
|
||||
className="rounded px-3 py-2 !text-[var(--color-muted)] transition-colors hover:bg-[var(--color-surface-2)] hover:!text-[var(--color-ink)]"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main id="main" className="mx-auto max-w-6xl px-6 py-8">
|
||||
{children}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { getDb } from "@/lib/db";
|
||||
import { listPublishedWithMetrics } from "@/lib/handlers";
|
||||
import { getPrincipal } from "@/lib/auth";
|
||||
import { buildHeatmap, DAY_LABELS } from "@/lib/heatmap";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const HOURS = Array.from({ length: 24 }, (_, h) => h);
|
||||
|
||||
export default async function MetricsPage() {
|
||||
const principal = await getPrincipal();
|
||||
if (!principal) {
|
||||
return (
|
||||
<p className="text-[var(--color-muted)]">Not authenticated. Sign in via Authentik.</p>
|
||||
);
|
||||
}
|
||||
const rows = await listPublishedWithMetrics(getDb());
|
||||
const cells = buildHeatmap(rows);
|
||||
const byKey = new Map(cells.map((c) => [`${c.day}-${c.hour}`, c]));
|
||||
const maxEng = Math.max(1, ...cells.map((c) => c.engagement));
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Engagement heatmap</h1>
|
||||
<p className="mt-1 text-sm text-[var(--color-muted)]">
|
||||
Published posts by AST day × hour. Each cell shows the post count; background
|
||||
intensity is total engagement (reactions + comments + shares). {rows.length} published.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 overflow-x-auto">
|
||||
<table className="border-collapse text-xs">
|
||||
<caption className="sr-only">
|
||||
Posts and engagement by day of week and hour, Atlantic Standard Time
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" className="p-1" />
|
||||
{HOURS.map((h) => (
|
||||
<th key={h} scope="col" className="p-1 font-normal text-[var(--color-muted)]">
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{DAY_LABELS.map((label, day) => (
|
||||
<tr key={day}>
|
||||
<th scope="row" className="p-1 pr-2 text-right font-normal text-[var(--color-muted)]">
|
||||
{label}
|
||||
</th>
|
||||
{HOURS.map((h) => {
|
||||
const c = byKey.get(`${day}-${h}`);
|
||||
const intensity = c ? c.engagement / maxEng : 0;
|
||||
const hh = String(h).padStart(2, "0");
|
||||
const labelText = c
|
||||
? `${label} ${hh}:00 — ${c.count} post${c.count === 1 ? "" : "s"}, ${c.engagement} engagement`
|
||||
: `${label} ${hh}:00 — no posts`;
|
||||
return (
|
||||
<td
|
||||
key={h}
|
||||
title={labelText}
|
||||
aria-label={labelText}
|
||||
className="h-7 w-7 border border-[var(--color-border)] text-center"
|
||||
style={{
|
||||
backgroundColor: c ? `rgba(153,204,0,${0.15 + intensity * 0.6})` : "transparent",
|
||||
color: c ? "#0e1303" : "transparent",
|
||||
}}
|
||||
>
|
||||
{c ? c.count : ""}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function Home() {
|
||||
redirect("/queue");
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { getPublicationDetail } from "@/lib/handlers";
|
||||
import { getPrincipal } from "@/lib/auth";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
import { fmtAst } from "@/lib/format";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function PublicationDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const principal = await getPrincipal();
|
||||
if (!principal) {
|
||||
return (
|
||||
<p className="text-[var(--color-muted)]">Not authenticated. Sign in via Authentik.</p>
|
||||
);
|
||||
}
|
||||
const { id } = await params;
|
||||
const pid = Number(id);
|
||||
if (!Number.isInteger(pid) || pid <= 0) notFound();
|
||||
|
||||
const detail = await getPublicationDetail(getDb(), pid);
|
||||
if (!detail) notFound();
|
||||
const { pub, metrics } = detail;
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{pub.title}</h1>
|
||||
<p className="mt-1 text-sm text-[var(--color-muted)]">{pub.slug}</p>
|
||||
</div>
|
||||
|
||||
<dl className="grid grid-cols-2 gap-4 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-6 sm:grid-cols-3">
|
||||
<div>
|
||||
<dt className="text-xs uppercase tracking-wide text-[var(--color-muted)]">Outlet</dt>
|
||||
<dd className="mt-1">{pub.outlet}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs uppercase tracking-wide text-[var(--color-muted)]">Status</dt>
|
||||
<dd className="mt-1">
|
||||
<StatusBadge status={pub.status} />
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs uppercase tracking-wide text-[var(--color-muted)]">Scheduled</dt>
|
||||
<dd className="mt-1 text-[var(--color-muted)]">{fmtAst(pub.scheduled_at)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs uppercase tracking-wide text-[var(--color-muted)]">Published</dt>
|
||||
<dd className="mt-1 text-[var(--color-muted)]">{fmtAst(pub.published_at)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs uppercase tracking-wide text-[var(--color-muted)]">External</dt>
|
||||
<dd className="mt-1">
|
||||
{pub.external_url ? (
|
||||
<a href={pub.external_url} target="_blank" rel="noopener noreferrer">
|
||||
{pub.external_id ?? "view"} ↗
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-[var(--color-muted)]">—</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="col-span-2 sm:col-span-3">
|
||||
<dt className="text-xs uppercase tracking-wide text-[var(--color-muted)]">
|
||||
Idempotency key
|
||||
</dt>
|
||||
<dd className="mt-1 font-mono text-xs text-[var(--color-muted)]">
|
||||
{pub.idempotency_key}
|
||||
</dd>
|
||||
</div>
|
||||
{pub.error && (
|
||||
<div className="col-span-2 sm:col-span-3">
|
||||
<dt className="text-xs uppercase tracking-wide text-[var(--color-muted)]">Error</dt>
|
||||
<dd className="mt-1 text-sm text-[var(--color-danger)]">{pub.error}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Metrics</h2>
|
||||
{metrics.length === 0 ? (
|
||||
<p className="mt-2 text-sm text-[var(--color-muted)]">No metrics collected yet.</p>
|
||||
) : (
|
||||
<table className="mt-3 w-full text-sm">
|
||||
<thead className="text-left text-[var(--color-muted)]">
|
||||
<tr>
|
||||
<th scope="col" className="py-1 font-medium">Collected</th>
|
||||
<th scope="col" className="py-1 font-medium">Impr.</th>
|
||||
<th scope="col" className="py-1 font-medium">React.</th>
|
||||
<th scope="col" className="py-1 font-medium">Comments</th>
|
||||
<th scope="col" className="py-1 font-medium">Shares</th>
|
||||
<th scope="col" className="py-1 font-medium">Clicks</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{metrics.map((m) => (
|
||||
<tr key={m.id} className="border-t border-[var(--color-border)]">
|
||||
<td className="py-1 text-[var(--color-muted)]">{fmtAst(m.collected_at)}</td>
|
||||
<td className="py-1">{m.impressions}</td>
|
||||
<td className="py-1">{m.reactions}</td>
|
||||
<td className="py-1">{m.comments}</td>
|
||||
<td className="py-1">{m.shares}</td>
|
||||
<td className="py-1">{m.clicks}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import Link from "next/link";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { listQueue } from "@/lib/handlers";
|
||||
import { getPrincipal } from "@/lib/auth";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
import { CancelButton } from "@/components/CancelButton";
|
||||
import { fmtAst } from "@/lib/format";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const CANCELLABLE = new Set(["pending", "queued", "scheduled", "dispatching", "failed"]);
|
||||
|
||||
export default async function QueuePage() {
|
||||
const principal = await getPrincipal();
|
||||
if (!principal) {
|
||||
return (
|
||||
<p className="text-[var(--color-muted)]">Not authenticated. Sign in via Authentik.</p>
|
||||
);
|
||||
}
|
||||
const rows = await listQueue(getDb());
|
||||
const canCancel = principal.roles.includes("operator");
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Publish queue</h1>
|
||||
<p className="mt-1 text-sm text-[var(--color-muted)]">
|
||||
Signed in as {principal.sub} · {principal.roles.join(", ")}
|
||||
</p>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<p className="mt-8 text-[var(--color-muted)]">Nothing queued.</p>
|
||||
) : (
|
||||
<div className="mt-6 overflow-x-auto rounded-lg border border-[var(--color-border)]">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-[var(--color-surface)] text-left text-[var(--color-muted)]">
|
||||
<tr>
|
||||
<th scope="col" className="px-4 py-2 font-medium">Title</th>
|
||||
<th scope="col" className="px-4 py-2 font-medium">Outlet</th>
|
||||
<th scope="col" className="px-4 py-2 font-medium">Status</th>
|
||||
<th scope="col" className="px-4 py-2 font-medium">Scheduled</th>
|
||||
<th scope="col" className="px-4 py-2 font-medium">Published</th>
|
||||
<th scope="col" className="px-4 py-2 font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id} className="border-t border-[var(--color-border)]">
|
||||
<td className="px-4 py-2">
|
||||
<Link
|
||||
href={`/publications/${r.id}`}
|
||||
className="!text-[var(--color-ink)] underline-offset-2 hover:underline"
|
||||
>
|
||||
{r.title}
|
||||
</Link>
|
||||
<span className="ml-2 text-xs text-[var(--color-muted)]">{r.slug}</span>
|
||||
</td>
|
||||
<td className="px-4 py-2">{r.outlet}</td>
|
||||
<td className="px-4 py-2">
|
||||
<StatusBadge status={r.status} />
|
||||
</td>
|
||||
<td className="px-4 py-2 text-[var(--color-muted)]">{fmtAst(r.scheduled_at)}</td>
|
||||
<td className="px-4 py-2 text-[var(--color-muted)]">{fmtAst(r.published_at)}</td>
|
||||
<td className="px-4 py-2">
|
||||
{canCancel && CANCELLABLE.has(r.status) ? (
|
||||
<CancelButton publicationId={r.id} />
|
||||
) : (
|
||||
<span className="text-xs text-[var(--color-muted)]">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export function CancelButton({ publicationId }: { publicationId: number }) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
async function onCancel() {
|
||||
const reason = window.prompt("Reason for cancelling this publication?");
|
||||
if (!reason) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/publications/cancel", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ publication_id: publicationId, reason }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
setError(data.error ?? `Failed (${res.status})`);
|
||||
return;
|
||||
}
|
||||
router.refresh();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={busy}
|
||||
aria-busy={busy}
|
||||
className="rounded border border-[var(--color-border)] px-2 py-1 text-xs text-[var(--color-muted)] transition-colors hover:border-[var(--color-danger)] hover:text-[var(--color-danger)] disabled:opacity-50"
|
||||
>
|
||||
{busy ? "Cancelling…" : "Cancel"}
|
||||
</button>
|
||||
{error && (
|
||||
<span role="alert" className="text-xs text-[var(--color-danger)]">
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export function FlagToggle({
|
||||
outlet,
|
||||
enabled,
|
||||
}: {
|
||||
outlet: string;
|
||||
enabled: boolean;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
const next = !enabled;
|
||||
|
||||
async function onToggle() {
|
||||
const reason =
|
||||
window.prompt(
|
||||
`Reason for turning ${outlet} publishing ${next ? "ON" : "OFF"}?`,
|
||||
) ?? "";
|
||||
if (!reason.trim()) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/feature-flags", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ outlet, enabled: next, reason }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
setError(data.error ?? `Failed (${res.status})`);
|
||||
return;
|
||||
}
|
||||
router.refresh();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
disabled={busy}
|
||||
aria-busy={busy}
|
||||
aria-pressed={enabled}
|
||||
className="rounded px-3 py-1 text-xs font-medium transition-colors disabled:opacity-50"
|
||||
style={{
|
||||
backgroundColor: enabled ? "rgba(78,130,17,0.25)" : "rgba(239,68,68,0.2)",
|
||||
color: enabled ? "#bfe35a" : "#fca5a5",
|
||||
}}
|
||||
>
|
||||
{busy ? "…" : next ? `Enable ${outlet}` : `Disable ${outlet}`}
|
||||
</button>
|
||||
{error && (
|
||||
<span role="alert" className="text-xs text-[var(--color-danger)]">
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Status badge — color + text (never color alone, WCAG 1.4.1). Uses the cool
|
||||
* operator palette: forest/citrus for healthy, slate for in-flight, red for
|
||||
* failure. Amber is deliberately absent (reserved for Hearth content).
|
||||
*/
|
||||
const STYLES: Record<string, { bg: string; fg: string }> = {
|
||||
published: { bg: "rgba(78,130,17,0.25)", fg: "#bfe35a" },
|
||||
dispatching: { bg: "rgba(153,204,0,0.18)", fg: "#cdec7a" },
|
||||
queued: { bg: "rgba(100,116,139,0.25)", fg: "#cbd5e1" },
|
||||
scheduled: { bg: "rgba(100,116,139,0.25)", fg: "#cbd5e1" },
|
||||
pending: { bg: "rgba(100,116,139,0.20)", fg: "#aab4c4" },
|
||||
failed: { bg: "rgba(239,68,68,0.20)", fg: "#fca5a5" },
|
||||
dlq: { bg: "rgba(239,68,68,0.28)", fg: "#fecaca" },
|
||||
cancelled: { bg: "rgba(120,120,120,0.20)", fg: "#cfcfcf" },
|
||||
};
|
||||
|
||||
export function StatusBadge({ status }: { status: string }) {
|
||||
const s = STYLES[status] ?? { bg: "rgba(120,120,120,0.2)", fg: "#cfcfcf" };
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium"
|
||||
style={{ backgroundColor: s.bg, color: s.fg }}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import "server-only";
|
||||
import { z } from "zod";
|
||||
import { AuthzError, type Principal } from "@stargue/schema";
|
||||
import { getPrincipal } from "./auth";
|
||||
import { NotFoundError, ConflictError } from "./handlers";
|
||||
|
||||
export function jsonError(
|
||||
status: number,
|
||||
error: string,
|
||||
extra?: Record<string, unknown>,
|
||||
): Response {
|
||||
return Response.json({ error, ...(extra ?? {}) }, { status });
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a mutating route handler with the full gate chain:
|
||||
* 401 unauthenticated → 403 forbidden (authz) → 422 invalid body →
|
||||
* 404 not found → 409 conflict → 500.
|
||||
* The handler itself enforces role authz via `requireAllowed`; this maps the
|
||||
* resulting errors to HTTP status codes and threads a correlation id.
|
||||
*/
|
||||
export async function handleMutation<S extends z.ZodTypeAny>(
|
||||
req: Request,
|
||||
schema: S,
|
||||
fn: (input: z.infer<S>, principal: Principal, correlationId: string) => Promise<unknown>,
|
||||
): Promise<Response> {
|
||||
const correlationId = crypto.randomUUID();
|
||||
try {
|
||||
const principal = await getPrincipal();
|
||||
if (!principal) return jsonError(401, "unauthenticated");
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const parsed = schema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return jsonError(422, "validation_failed", { issues: parsed.error.issues });
|
||||
}
|
||||
|
||||
const result = await fn(parsed.data, principal, correlationId);
|
||||
return Response.json({ ok: true, data: result, correlationId }, { status: 200 });
|
||||
} catch (err) {
|
||||
if (err instanceof AuthzError) return jsonError(403, "forbidden");
|
||||
if (err instanceof NotFoundError) return jsonError(404, err.message);
|
||||
if (err instanceof ConflictError) return jsonError(409, err.message);
|
||||
console.error(`[admin] route error (corr=${correlationId}):`, err);
|
||||
return jsonError(500, "internal_error", { correlationId });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { principalFromAuthentik, principalFromDevEnv } from "./auth-core";
|
||||
|
||||
describe("principalFromAuthentik", () => {
|
||||
it("maps Authentik groups to roles", () => {
|
||||
const p = principalFromAuthentik(
|
||||
"angelo",
|
||||
"stargue-pe-operators|stargue-pe-approvers",
|
||||
);
|
||||
expect(p).toEqual({ sub: "angelo", roles: ["operator", "approver"] });
|
||||
});
|
||||
|
||||
it("returns null when no username", () => {
|
||||
expect(principalFromAuthentik(null, "stargue-pe-operators")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when no mappable groups", () => {
|
||||
expect(principalFromAuthentik("angelo", "some-other-group")).toBeNull();
|
||||
expect(principalFromAuthentik("angelo", "")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("principalFromDevEnv", () => {
|
||||
it("parses sub:roles", () => {
|
||||
expect(principalFromDevEnv("angelo:operator,approver")).toEqual({
|
||||
sub: "angelo",
|
||||
roles: ["operator", "approver"],
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults to operator when only a sub is given", () => {
|
||||
expect(principalFromDevEnv("angelo")).toEqual({ sub: "angelo", roles: ["operator"] });
|
||||
});
|
||||
|
||||
it("returns null for undefined / empty", () => {
|
||||
expect(principalFromDevEnv(undefined)).toBeNull();
|
||||
expect(principalFromDevEnv("")).toBeNull();
|
||||
expect(principalFromDevEnv(":operator")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Principal, Role } from "@stargue/schema";
|
||||
|
||||
/**
|
||||
* Maps Authentik group names to Publishing-Engine roles. The forward-auth proxy
|
||||
* (Authentik outpost) injects the authenticated user's groups; we translate them
|
||||
* to the authz roles consumed by `@stargue/schema` `requireAllowed`.
|
||||
*
|
||||
* Pure module (no next/* imports) so it is unit-testable without a server.
|
||||
*/
|
||||
export const GROUP_ROLE_MAP: Record<string, Role> = {
|
||||
"stargue-pe-operators": "operator",
|
||||
"stargue-pe-approvers": "approver",
|
||||
"stargue-pe-viewers": "viewer",
|
||||
};
|
||||
|
||||
/** Resolve a principal from Authentik forward-auth headers (groups separated by `|`). */
|
||||
export function principalFromAuthentik(
|
||||
username: string | null,
|
||||
groupsHeader: string | null,
|
||||
): Principal | null {
|
||||
if (!username) return null;
|
||||
const groups = (groupsHeader ?? "")
|
||||
.split("|")
|
||||
.map((g) => g.trim())
|
||||
.filter(Boolean);
|
||||
const roles = groups
|
||||
.map((g) => GROUP_ROLE_MAP[g])
|
||||
.filter((r): r is Role => Boolean(r));
|
||||
if (roles.length === 0) return null;
|
||||
return { sub: username, roles };
|
||||
}
|
||||
|
||||
/**
|
||||
* Dev-only stub principal from `AUTH_DEV_PRINCIPAL` (format `sub:role1,role2`,
|
||||
* e.g. `angelo:operator,approver`). Lets the dashboard run without a live IdP.
|
||||
*/
|
||||
export function principalFromDevEnv(dev: string | undefined): Principal | null {
|
||||
if (!dev) return null;
|
||||
const idx = dev.indexOf(":");
|
||||
const sub = (idx === -1 ? dev : dev.slice(0, idx)).trim();
|
||||
const rolesCsv = idx === -1 ? "operator" : dev.slice(idx + 1);
|
||||
const roles = rolesCsv
|
||||
.split(",")
|
||||
.map((r) => r.trim())
|
||||
.filter(Boolean) as Role[];
|
||||
if (!sub || roles.length === 0) return null;
|
||||
return { sub, roles };
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import "server-only";
|
||||
import { headers } from "next/headers";
|
||||
import type { Principal } from "@stargue/schema";
|
||||
import { principalFromAuthentik, principalFromDevEnv } from "./auth-core";
|
||||
|
||||
/**
|
||||
* Resolve the current principal. Dev stub (`AUTH_DEV_PRINCIPAL`) takes precedence
|
||||
* for local development; otherwise reads the Authentik forward-auth headers.
|
||||
* Returns null when unauthenticated (callers map this to HTTP 401 / login).
|
||||
*/
|
||||
export async function getPrincipal(): Promise<Principal | null> {
|
||||
const dev = principalFromDevEnv(process.env.AUTH_DEV_PRINCIPAL);
|
||||
if (dev) return dev;
|
||||
const h = await headers();
|
||||
return principalFromAuthentik(
|
||||
h.get("x-authentik-username"),
|
||||
h.get("x-authentik-groups"),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import "server-only";
|
||||
import { createDb, type Db } from "@stargue/schema/client";
|
||||
|
||||
let _db: Db | null = null;
|
||||
|
||||
/** Lazily-created singleton DB handle for server components + route handlers. */
|
||||
export function getDb(): Db {
|
||||
if (!_db) {
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) throw new Error("DATABASE_URL is required");
|
||||
_db = createDb(url).db;
|
||||
}
|
||||
return _db;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { AST_OFFSET_MS } from "./heatmap";
|
||||
|
||||
/** Format a UTC timestamp as `YYYY-MM-DD HH:MM AST` (Curaçao, no DST). */
|
||||
export function fmtAst(d: Date | null | undefined): string {
|
||||
if (!d) return "—";
|
||||
return (
|
||||
new Date(d.getTime() + AST_OFFSET_MS).toISOString().slice(0, 16).replace("T", " ") +
|
||||
" AST"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { createDb, type DbHandle } from "@stargue/schema/client";
|
||||
import { AuthzError, type Principal, type CreateContentRequest } from "@stargue/schema";
|
||||
import { content, publications, audit } from "@stargue/schema/db";
|
||||
import {
|
||||
createContent,
|
||||
createApproval,
|
||||
cancelPublication,
|
||||
toggleFeatureFlag,
|
||||
NotFoundError,
|
||||
ConflictError,
|
||||
} from "./handlers";
|
||||
|
||||
/**
|
||||
* Admin handler tests — authz tiers + happy paths against real Postgres.
|
||||
* Gated behind INTEGRATION=1. Covers the Stage 2.3 route-handler intent
|
||||
* (401/403/404/409/200 logic) at the handler layer, testable without a server.
|
||||
*/
|
||||
const RUN = !!process.env.INTEGRATION;
|
||||
const URL =
|
||||
process.env.DATABASE_URL ??
|
||||
"postgres://stargue:stargue_dev@localhost:5433/stargue_publishing_engine";
|
||||
|
||||
const operator: Principal = { sub: "angelo", roles: ["operator"] };
|
||||
const approver: Principal = { sub: "carlo", roles: ["approver"] };
|
||||
const viewer: Principal = { sub: "guest", roles: ["viewer"] };
|
||||
|
||||
const baseFrontmatter = {
|
||||
status: "ready" as const,
|
||||
language: "en" as const,
|
||||
outlets: [{ outlet: "linkedin.org" as const, status: "queued" as const, published_url: null }],
|
||||
scheduled: null,
|
||||
category: "blog" as const,
|
||||
canonical: "stargue.com",
|
||||
sanitize: true,
|
||||
version: 1,
|
||||
};
|
||||
|
||||
const contentInput = (slug = "x"): CreateContentRequest => ({
|
||||
vault_path: `Stargue/Projects/Publishing Engine/${slug}.md`,
|
||||
slug,
|
||||
title: "X",
|
||||
body_sanitized: "body",
|
||||
frontmatter: baseFrontmatter,
|
||||
content_hash: "c".repeat(64),
|
||||
});
|
||||
|
||||
describe.skipIf(!RUN)("admin handlers — real Postgres (Stage 2.3 / 5)", () => {
|
||||
let h: DbHandle;
|
||||
beforeAll(() => {
|
||||
h = createDb(URL);
|
||||
});
|
||||
afterAll(async () => {
|
||||
await h.sql.end();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await h.sql`TRUNCATE content, publications, approvals, metrics, audit, outlet_feature_flags RESTART IDENTITY CASCADE`;
|
||||
});
|
||||
|
||||
it("createContent: operator allowed → row + audit", async () => {
|
||||
const row = await createContent(h.db, operator, contentInput(), "corr-1");
|
||||
expect(row.slug).toBe("x");
|
||||
const auditRows = await h.db.select().from(audit).where(eq(audit.correlation_id, "corr-1"));
|
||||
expect(auditRows).toHaveLength(1);
|
||||
expect(auditRows[0]!.action).toBe("content.create");
|
||||
expect(auditRows[0]!.actor).toBe("angelo");
|
||||
});
|
||||
|
||||
it("createContent: viewer forbidden (AuthzError) and writes nothing", async () => {
|
||||
await expect(
|
||||
createContent(h.db, viewer, contentInput("y"), "corr-2"),
|
||||
).rejects.toBeInstanceOf(AuthzError);
|
||||
expect(await h.db.select().from(content)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("createApproval: approver allowed; missing content → NotFoundError", async () => {
|
||||
const c = await createContent(h.db, operator, contentInput("z"), "corr-3");
|
||||
const appr = await createApproval(
|
||||
h.db,
|
||||
approver,
|
||||
{ content_id: c.id, outlet: "linkedin.org", notes: "lgtm" },
|
||||
"corr-4",
|
||||
);
|
||||
expect(appr.approved_by).toBe("carlo");
|
||||
await expect(
|
||||
createApproval(h.db, approver, { content_id: 99999, outlet: "linkedin.org" }, "corr-5"),
|
||||
).rejects.toBeInstanceOf(NotFoundError);
|
||||
});
|
||||
|
||||
it("cancelPublication: operator cancels queued; published → ConflictError", async () => {
|
||||
const c = await createContent(h.db, operator, contentInput("p"), "corr-6");
|
||||
const [pub] = await h.db
|
||||
.insert(publications)
|
||||
.values({
|
||||
content_id: c.id,
|
||||
outlet: "linkedin.org",
|
||||
status: "queued",
|
||||
idempotency_key: `${c.id}|linkedin.org|immediate`,
|
||||
})
|
||||
.returning();
|
||||
const cancelled = await cancelPublication(
|
||||
h.db,
|
||||
operator,
|
||||
{ publication_id: pub!.id, reason: "typo" },
|
||||
"corr-7",
|
||||
);
|
||||
expect(cancelled.status).toBe("cancelled");
|
||||
|
||||
const [pub2] = await h.db
|
||||
.insert(publications)
|
||||
.values({
|
||||
content_id: c.id,
|
||||
outlet: "linkedin.member",
|
||||
status: "published",
|
||||
idempotency_key: `${c.id}|linkedin.member|immediate`,
|
||||
})
|
||||
.returning();
|
||||
await expect(
|
||||
cancelPublication(h.db, operator, { publication_id: pub2!.id, reason: "x" }, "corr-8"),
|
||||
).rejects.toBeInstanceOf(ConflictError);
|
||||
});
|
||||
|
||||
it("toggleFeatureFlag: operator upserts the kill-switch", async () => {
|
||||
const off = await toggleFeatureFlag(
|
||||
h.db,
|
||||
operator,
|
||||
{ outlet: "linkedin.org", enabled: false, reason: "auth expired" },
|
||||
"corr-9",
|
||||
);
|
||||
expect(off.enabled).toBe(false);
|
||||
const on = await toggleFeatureFlag(
|
||||
h.db,
|
||||
operator,
|
||||
{ outlet: "linkedin.org", enabled: true, reason: "restored" },
|
||||
"corr-10",
|
||||
);
|
||||
expect(on.enabled).toBe(true);
|
||||
expect(on.outlet).toBe("linkedin.org");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
import { eq, desc, and, isNotNull } from "drizzle-orm";
|
||||
import type { Db } from "@stargue/schema/client";
|
||||
import {
|
||||
content,
|
||||
publications,
|
||||
approvals,
|
||||
outlet_feature_flags,
|
||||
metrics,
|
||||
audit,
|
||||
} from "@stargue/schema/db";
|
||||
import {
|
||||
requireAllowed,
|
||||
type Principal,
|
||||
type CreateContentRequest,
|
||||
type CreateApprovalRequest,
|
||||
type CancelPublicationRequest,
|
||||
type ToggleFeatureFlagRequest,
|
||||
} from "@stargue/schema";
|
||||
|
||||
export class NotFoundError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "NotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ConflictError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ConflictError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Append-only audit write. Every state-changing handler calls this. */
|
||||
async function writeAudit(
|
||||
db: Db,
|
||||
principal: Principal,
|
||||
action: string,
|
||||
subjectType: string,
|
||||
subjectId: number | string,
|
||||
correlationId: string,
|
||||
payload: Record<string, unknown> | null,
|
||||
): Promise<void> {
|
||||
await db.insert(audit).values({
|
||||
actor: principal.sub,
|
||||
action,
|
||||
subject_type: subjectType,
|
||||
subject_id: String(subjectId),
|
||||
correlation_id: correlationId,
|
||||
payload_jsonb: payload,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Mutations (authz-gated, audited) ----
|
||||
|
||||
export async function createContent(
|
||||
db: Db,
|
||||
principal: Principal,
|
||||
input: CreateContentRequest,
|
||||
correlationId: string,
|
||||
) {
|
||||
requireAllowed(principal, "content.create");
|
||||
const [row] = await db
|
||||
.insert(content)
|
||||
.values({
|
||||
vault_path: input.vault_path,
|
||||
slug: input.slug,
|
||||
title: input.title,
|
||||
body_sanitized: input.body_sanitized,
|
||||
frontmatter_jsonb: input.frontmatter,
|
||||
content_hash: input.content_hash,
|
||||
})
|
||||
.returning();
|
||||
await writeAudit(db, principal, "content.create", "content", row!.id, correlationId, {
|
||||
slug: input.slug,
|
||||
});
|
||||
return row!;
|
||||
}
|
||||
|
||||
export async function createApproval(
|
||||
db: Db,
|
||||
principal: Principal,
|
||||
input: CreateApprovalRequest,
|
||||
correlationId: string,
|
||||
) {
|
||||
requireAllowed(principal, "approvals.create");
|
||||
const [c] = await db.select().from(content).where(eq(content.id, input.content_id));
|
||||
if (!c) throw new NotFoundError(`content ${input.content_id} not found`);
|
||||
const [row] = await db
|
||||
.insert(approvals)
|
||||
.values({
|
||||
content_id: input.content_id,
|
||||
outlet: input.outlet,
|
||||
approved_by: principal.sub,
|
||||
notes: input.notes ?? null,
|
||||
})
|
||||
.returning();
|
||||
await writeAudit(db, principal, "approvals.create", "approval", row!.id, correlationId, {
|
||||
content_id: input.content_id,
|
||||
outlet: input.outlet,
|
||||
});
|
||||
return row!;
|
||||
}
|
||||
|
||||
export async function cancelPublication(
|
||||
db: Db,
|
||||
principal: Principal,
|
||||
input: CancelPublicationRequest,
|
||||
correlationId: string,
|
||||
) {
|
||||
requireAllowed(principal, "publications.cancel");
|
||||
const [pub] = await db
|
||||
.select()
|
||||
.from(publications)
|
||||
.where(eq(publications.id, input.publication_id));
|
||||
if (!pub) throw new NotFoundError(`publication ${input.publication_id} not found`);
|
||||
if (pub.status === "published" || pub.status === "cancelled") {
|
||||
throw new ConflictError(`cannot cancel a ${pub.status} publication`);
|
||||
}
|
||||
const [row] = await db
|
||||
.update(publications)
|
||||
.set({ status: "cancelled", error: input.reason })
|
||||
.where(eq(publications.id, input.publication_id))
|
||||
.returning();
|
||||
await writeAudit(
|
||||
db,
|
||||
principal,
|
||||
"publications.cancel",
|
||||
"publication",
|
||||
input.publication_id,
|
||||
correlationId,
|
||||
{ reason: input.reason },
|
||||
);
|
||||
return row!;
|
||||
}
|
||||
|
||||
export async function toggleFeatureFlag(
|
||||
db: Db,
|
||||
principal: Principal,
|
||||
input: ToggleFeatureFlagRequest,
|
||||
correlationId: string,
|
||||
) {
|
||||
requireAllowed(principal, "feature_flags.toggle");
|
||||
const [row] = await db
|
||||
.insert(outlet_feature_flags)
|
||||
.values({
|
||||
outlet: input.outlet,
|
||||
enabled: input.enabled,
|
||||
reason: input.reason,
|
||||
updated_by: principal.sub,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: outlet_feature_flags.outlet,
|
||||
set: {
|
||||
enabled: input.enabled,
|
||||
reason: input.reason,
|
||||
updated_by: principal.sub,
|
||||
updated_at: new Date(),
|
||||
},
|
||||
})
|
||||
.returning();
|
||||
await writeAudit(
|
||||
db,
|
||||
principal,
|
||||
"feature_flags.toggle",
|
||||
"outlet",
|
||||
input.outlet,
|
||||
correlationId,
|
||||
{ enabled: input.enabled, reason: input.reason },
|
||||
);
|
||||
return row!;
|
||||
}
|
||||
|
||||
// ---- Reads (for pages) ----
|
||||
|
||||
export async function listQueue(db: Db) {
|
||||
return db
|
||||
.select({
|
||||
id: publications.id,
|
||||
outlet: publications.outlet,
|
||||
status: publications.status,
|
||||
scheduled_at: publications.scheduled_at,
|
||||
published_at: publications.published_at,
|
||||
external_url: publications.external_url,
|
||||
error: publications.error,
|
||||
content_id: content.id,
|
||||
title: content.title,
|
||||
slug: content.slug,
|
||||
})
|
||||
.from(publications)
|
||||
.innerJoin(content, eq(publications.content_id, content.id))
|
||||
.orderBy(desc(publications.scheduled_at))
|
||||
.limit(200);
|
||||
}
|
||||
|
||||
export async function getPublicationDetail(db: Db, id: number) {
|
||||
const [pub] = await db
|
||||
.select({
|
||||
id: publications.id,
|
||||
outlet: publications.outlet,
|
||||
status: publications.status,
|
||||
scheduled_at: publications.scheduled_at,
|
||||
published_at: publications.published_at,
|
||||
external_id: publications.external_id,
|
||||
external_url: publications.external_url,
|
||||
error: publications.error,
|
||||
idempotency_key: publications.idempotency_key,
|
||||
title: content.title,
|
||||
slug: content.slug,
|
||||
})
|
||||
.from(publications)
|
||||
.innerJoin(content, eq(publications.content_id, content.id))
|
||||
.where(eq(publications.id, id));
|
||||
if (!pub) return null;
|
||||
const metricRows = await db
|
||||
.select()
|
||||
.from(metrics)
|
||||
.where(eq(metrics.publication_id, id))
|
||||
.orderBy(desc(metrics.collected_at));
|
||||
return { pub, metrics: metricRows };
|
||||
}
|
||||
|
||||
export async function listFeatureFlags(db: Db) {
|
||||
return db.select().from(outlet_feature_flags).orderBy(outlet_feature_flags.outlet);
|
||||
}
|
||||
|
||||
export async function listPublishedWithMetrics(db: Db) {
|
||||
return db
|
||||
.select({
|
||||
id: publications.id,
|
||||
outlet: publications.outlet,
|
||||
published_at: publications.published_at,
|
||||
impressions: metrics.impressions,
|
||||
reactions: metrics.reactions,
|
||||
comments: metrics.comments,
|
||||
shares: metrics.shares,
|
||||
})
|
||||
.from(publications)
|
||||
.leftJoin(metrics, eq(metrics.publication_id, publications.id))
|
||||
.where(and(eq(publications.status, "published"), isNotNull(publications.published_at)))
|
||||
.orderBy(desc(publications.published_at))
|
||||
.limit(500);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildHeatmap, AST_OFFSET_MS } from "./heatmap";
|
||||
|
||||
describe("buildHeatmap", () => {
|
||||
it("buckets by AST day/hour and sums engagement", () => {
|
||||
// 2026-06-04T12:00Z is Thursday; AST (-4) → 08:00 Thursday.
|
||||
const t = new Date("2026-06-04T12:00:00Z");
|
||||
const cells = buildHeatmap([
|
||||
{ published_at: t, impressions: 100, reactions: 5, comments: 2, shares: 1 },
|
||||
{ published_at: t, impressions: 50, reactions: 3, comments: 0, shares: 0 },
|
||||
]);
|
||||
expect(cells).toHaveLength(1);
|
||||
expect(cells[0]).toMatchObject({ day: 4, hour: 8, count: 2, engagement: 11 });
|
||||
});
|
||||
|
||||
it("skips rows with no published_at", () => {
|
||||
expect(
|
||||
buildHeatmap([
|
||||
{ published_at: null, impressions: 1, reactions: 1, comments: 1, shares: 1 },
|
||||
]),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("treats null metric components as zero", () => {
|
||||
const t = new Date("2026-06-04T12:00:00Z");
|
||||
const cells = buildHeatmap([
|
||||
{ published_at: t, impressions: null, reactions: null, comments: null, shares: null },
|
||||
]);
|
||||
expect(cells[0]).toMatchObject({ count: 1, engagement: 0 });
|
||||
});
|
||||
|
||||
it("AST offset is a constant UTC-4 (Curaçao, no DST)", () => {
|
||||
expect(AST_OFFSET_MS).toBe(-4 * 60 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Heatmap aggregation for the /metrics view. Pure + timezone-explicit so it is
|
||||
* unit-testable. Buckets published posts by AST day-of-week × hour. Curaçao
|
||||
* observes no DST, so the AST offset is a constant UTC-4.
|
||||
*/
|
||||
export const AST_OFFSET_MS = -4 * 60 * 60 * 1000;
|
||||
|
||||
export interface MetricRow {
|
||||
published_at: Date | null;
|
||||
impressions: number | null;
|
||||
reactions: number | null;
|
||||
comments: number | null;
|
||||
shares: number | null;
|
||||
}
|
||||
|
||||
export interface HeatCell {
|
||||
day: number; // 0=Sun … 6=Sat (AST)
|
||||
hour: number; // 0…23 (AST)
|
||||
count: number;
|
||||
engagement: number; // reactions + comments + shares
|
||||
}
|
||||
|
||||
export function buildHeatmap(rows: MetricRow[]): HeatCell[] {
|
||||
const cells = new Map<string, HeatCell>();
|
||||
for (const r of rows) {
|
||||
if (!r.published_at) continue;
|
||||
const ast = new Date(r.published_at.getTime() + AST_OFFSET_MS);
|
||||
const day = ast.getUTCDay();
|
||||
const hour = ast.getUTCHours();
|
||||
const key = `${day}-${hour}`;
|
||||
const engagement = (r.reactions ?? 0) + (r.comments ?? 0) + (r.shares ?? 0);
|
||||
const cell = cells.get(key) ?? { day, hour, count: 0, engagement: 0 };
|
||||
cell.count += 1;
|
||||
cell.engagement += engagement;
|
||||
cells.set(key, cell);
|
||||
}
|
||||
return [...cells.values()].sort((a, b) => a.day - b.day || a.hour - b.hour);
|
||||
}
|
||||
|
||||
export const DAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const;
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"jsx": "preserve",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowJs": true,
|
||||
"noEmit": true,
|
||||
"incremental": true,
|
||||
"declaration": false,
|
||||
"declarationMap": false,
|
||||
"sourceMap": false,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"types": ["node", "react", "react-dom"],
|
||||
"paths": { "@/*": ["./src/*"] },
|
||||
"exactOptionalPropertyTypes": false
|
||||
},
|
||||
"include": ["next-env.d.ts", "src/**/*.ts", "src/**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules", ".next"]
|
||||
}
|
||||
Reference in New Issue
Block a user