feat(content): Phase 8 content generation (Faz A+B) for Sase.tr

Hybrid content automation pilot: generation + review + drafts live in the
panel (reusing the insight pipeline's DeepSeek client, prompt_templates
versioning, cost_ledger and budget_settings); publishing/distribution will
go through n8n (Faz C, not built). Channels: blog, LinkedIn, X, Instagram.
Topic sourcing is automatic (LLM-generated ideas). Approval model: drafts
sit in the panel for manual review/edit/publish.

Faz A (worker):
- ContentTopic / ContentDraft Prisma models (content_topics, content_drafts)
- content-prompts.ts: 5 seed prompts (topic ideas[pro] + blog[pro] +
  linkedin/x/instagram[flash]), Turkish B2B automotive tone, per-channel
  JSON schemas
- content-budget.ts: separate budget envelope (sums only content_* spend)
- content-topics job (auto idea gen, backlog-capped, title dedupe) +
  content-generate job (queued topic -> one draft per channel)
- content-pipeline scheduler (separate BullMQ queue, topics@*/8h,
  generate@*/10min), wired into index.ts; seeded via seed-runtime
- content budget settings (caps + content_paused kill switch); seed default
  content_paused=true for a safe first deploy

Faz B (web):
- /content (queue + auto/manual triggers + manual topic form),
  /content/t/[id] (per-channel draft cards: preview, JSON edits,
  approve/reject), /content/costs (content-only spend)
- server actions (audit-logged), manual trigger API routes, contentQueue(),
  nav + Cmd+K entries
- content caps surfaced on /insights/settings/budgets + whitelisted

Both packages typecheck. Schema applies on deploy (web start runs
prisma db push).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Semih
2026-05-24 00:11:05 +03:00
parent 565ad5af59
commit fdce3f6bd0
22 changed files with 1856 additions and 1 deletions

View File

@@ -364,6 +364,71 @@ model EvalRun {
@@map("eval_runs")
}
// ---------- Phase 8a: Content generation ----------
// A topic/brief for content generation. Either auto-generated by the
// content-topics job (LLM idea generation) or entered manually. One topic
// fans out into one ContentDraft per selected channel.
model ContentTopic {
id String @id @default(cuid())
projectKey String
title String
brief String @db.Text
angle String? @db.Text
channels String[] // ["blog","linkedin","x","instagram"]
keywords String[]
/// queued | generating | drafted | archived
status String @default("queued")
/// auto | manual
source String @default("auto")
fingerprint String? // dedupe near-identical auto topics
sourcePromptTag String?
sourcePromptVersion Int?
sourceModel String?
sourceCostUsd Float @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
drafts ContentDraft[]
@@index([projectKey, status])
@@index([fingerprint])
@@map("content_topics")
}
// One generated piece of content for a single channel. Lives as a draft in
// the panel; founder reviews/edits, then publishes via the n8n webhook
// (Phase 8c). bodyJson holds the channel-specific generated structure;
// founderEdits holds any human overrides applied before publish.
model ContentDraft {
id String @id @default(cuid())
topicId String
projectKey String
/// blog | linkedin | x | instagram
channel String
/// draft | approved | publishing | published | failed | rejected
status String @default("draft")
bodyJson Json
founderEdits Json?
publishedUrl String?
n8nExecutionId String?
publishError String?
sourcePromptTag String
sourcePromptVersion Int
sourceModel String
sourceCostUsd Float @default(0)
publishedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
topic ContentTopic @relation(fields: [topicId], references: [id], onDelete: Cascade)
@@index([projectKey, status])
@@index([topicId])
@@index([channel, status])
@@map("content_drafts")
}
// ---------- Phase 7a: Sase user-management ----------
// Founder-only notes pinned to a Sase user. Stored panel-side (KVKK minimize:

View File

@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { contentQueue } from "@/lib/queue";
import { writeAudit } from "@/lib/audit";
export const dynamic = "force-dynamic";
// Manually trigger one content-generate run (queued topics → channel drafts).
// Also scheduled every 10min; this is for on-demand kicks after queueing topics.
export async function POST() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return NextResponse.json({ ok: false, error: "unauthenticated" }, { status: 401 });
const job = await contentQueue().add(
"content-generate",
{},
{ removeOnComplete: 50, removeOnFail: 25 },
);
await writeAudit({
projectKey: "sase",
endpoint: "/api/content/generate",
method: "POST",
responseStatus: 200,
});
return NextResponse.json({ ok: true, jobId: job.id });
}

View File

@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { contentQueue } from "@/lib/queue";
import { writeAudit } from "@/lib/audit";
export const dynamic = "force-dynamic";
// Manually trigger one content-topics run (auto topic-idea generation).
// The job itself is also scheduled (every 8h) — this is for on-demand kicks.
export async function POST() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return NextResponse.json({ ok: false, error: "unauthenticated" }, { status: 401 });
const job = await contentQueue().add(
"content-topics",
{},
{ removeOnComplete: 30, removeOnFail: 15 },
);
await writeAudit({
projectKey: "sase",
endpoint: "/api/content/topics/generate",
method: "POST",
responseStatus: 200,
});
return NextResponse.json({ ok: true, jobId: job.id });
}

View File

@@ -0,0 +1,151 @@
"use server";
import { revalidatePath } from "next/cache";
import { headers } from "next/headers";
import { prisma } from "@/lib/db";
import { auth } from "@/lib/auth";
import { writeAudit } from "@/lib/audit";
import { contentQueue } from "@/lib/queue";
const PROJECT_KEY = "sase";
const VALID_CHANNELS = ["blog", "linkedin", "x", "instagram"];
// Draft lifecycle reachable from the UI. publishing/published/failed are set
// by the publish action + n8n callback (Phase 8c), not directly here.
const DRAFT_STATUS = new Set(["draft", "approved", "rejected"]);
async function requireSession() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) throw new Error("unauthenticated");
return session;
}
export async function createTopic(input: {
title: string;
brief: string;
angle?: string;
channels: string[];
keywords: string[];
}) {
await requireSession();
const title = input.title.trim().slice(0, 250);
if (!title) throw new Error("title required");
const channels = input.channels.filter((c) => VALID_CHANNELS.includes(c));
if (channels.length === 0) throw new Error("pick at least one channel");
const topic = await prisma.contentTopic.create({
data: {
projectKey: PROJECT_KEY,
title,
brief: input.brief.trim().slice(0, 2000),
angle: input.angle?.trim().slice(0, 1000) || null,
channels,
keywords: input.keywords.map((k) => k.trim()).filter(Boolean).slice(0, 12),
status: "queued",
source: "manual",
},
});
await writeAudit({
projectKey: PROJECT_KEY,
endpoint: "/content/topics",
method: "POST",
requestPayload: { title, channels },
responseStatus: 200,
});
revalidatePath("/content");
return topic.id;
}
export async function archiveTopic(topicId: string) {
await requireSession();
await prisma.contentTopic.update({ where: { id: topicId }, data: { status: "archived" } });
await writeAudit({
projectKey: PROJECT_KEY,
endpoint: `/content/topics/${topicId}/archive`,
method: "POST",
responseStatus: 200,
});
revalidatePath("/content");
revalidatePath(`/content/t/${topicId}`);
}
export async function requeueTopic(topicId: string) {
await requireSession();
await prisma.contentTopic.update({ where: { id: topicId }, data: { status: "queued" } });
await writeAudit({
projectKey: PROJECT_KEY,
endpoint: `/content/topics/${topicId}/requeue`,
method: "POST",
responseStatus: 200,
});
revalidatePath("/content");
revalidatePath(`/content/t/${topicId}`);
}
export async function enqueueTopicGeneration() {
await requireSession();
const job = await contentQueue().add("content-topics", {}, { removeOnComplete: 30, removeOnFail: 15 });
await writeAudit({
projectKey: PROJECT_KEY,
endpoint: "/content/topics/generate",
method: "POST",
responseStatus: 200,
});
revalidatePath("/content");
return job.id;
}
export async function enqueueContentGeneration() {
await requireSession();
const job = await contentQueue().add("content-generate", {}, { removeOnComplete: 50, removeOnFail: 25 });
await writeAudit({
projectKey: PROJECT_KEY,
endpoint: "/content/generate",
method: "POST",
responseStatus: 200,
});
revalidatePath("/content");
return job.id;
}
export async function saveDraftEdits(draftId: string, editsJson: string) {
await requireSession();
let parsed: unknown = null;
const trimmed = editsJson.trim();
if (trimmed) {
try {
parsed = JSON.parse(trimmed);
} catch (e) {
throw new Error(`invalid JSON: ${(e as Error).message}`);
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
throw new Error("edits must be a JSON object");
}
}
const draft = await prisma.contentDraft.update({
where: { id: draftId },
data: { founderEdits: parsed === null ? undefined : (parsed as object) },
});
await writeAudit({
projectKey: PROJECT_KEY,
endpoint: `/content/drafts/${draftId}/edits`,
method: "POST",
requestPayload: { length: trimmed.length },
responseStatus: 200,
});
revalidatePath(`/content/t/${draft.topicId}`);
}
export async function setDraftStatus(draftId: string, status: string) {
await requireSession();
if (!DRAFT_STATUS.has(status)) throw new Error(`bad status: ${status}`);
const draft = await prisma.contentDraft.update({ where: { id: draftId }, data: { status } });
await writeAudit({
projectKey: PROJECT_KEY,
endpoint: `/content/drafts/${draftId}/status`,
method: "POST",
requestPayload: { status },
responseStatus: 200,
});
revalidatePath(`/content/t/${draft.topicId}`);
revalidatePath("/content");
}

View File

@@ -0,0 +1,134 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import {
createTopic,
enqueueTopicGeneration,
enqueueContentGeneration,
} from "./_actions";
const CHANNELS = [
{ value: "blog", label: "Blog" },
{ value: "linkedin", label: "LinkedIn" },
{ value: "x", label: "X" },
{ value: "instagram", label: "Instagram" },
];
export function ContentControls({ queuedCount }: { queuedCount: number }) {
const router = useRouter();
const [pending, start] = useTransition();
const [flash, setFlash] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false);
// manual topic form state
const [title, setTitle] = useState("");
const [brief, setBrief] = useState("");
const [angle, setAngle] = useState("");
const [keywords, setKeywords] = useState("");
const [channels, setChannels] = useState<string[]>(["blog", "linkedin", "x", "instagram"]);
const fire = (fn: () => Promise<unknown>, label: string) =>
start(async () => {
try {
await fn();
setFlash(label);
setTimeout(() => setFlash(null), 2500);
router.refresh();
} catch (e) {
setFlash(`hata: ${(e as Error).message}`);
}
});
const toggleChannel = (c: string) =>
setChannels((prev) => (prev.includes(c) ? prev.filter((x) => x !== c) : [...prev, c]));
const submitTopic = () =>
fire(async () => {
await createTopic({
title,
brief,
angle,
channels,
keywords: keywords.split(",").map((k) => k.trim()).filter(Boolean),
});
setTitle("");
setBrief("");
setAngle("");
setKeywords("");
setShowForm(false);
}, "konu eklendi");
return (
<div className="space-y-3 rounded-md border p-3">
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="outline"
disabled={pending}
onClick={() => fire(() => enqueueTopicGeneration(), "konu üretimi kuyruğa alındı")}
>
Otomatik konu üret
</Button>
<Button
size="sm"
variant="outline"
disabled={pending}
onClick={() => fire(() => enqueueContentGeneration(), "üretim kuyruğa alındı")}
>
Taslakları üret ({queuedCount} kuyrukta)
</Button>
<Button size="sm" variant={showForm ? "default" : "outline"} onClick={() => setShowForm((v) => !v)}>
{showForm ? "Formu kapat" : "Elle konu ekle"}
</Button>
{flash && <span className="text-xs text-muted-foreground">{flash}</span>}
</div>
{showForm && (
<div className="space-y-2 border-t pt-3">
<input
className="w-full rounded border bg-background px-2 py-1 text-sm"
placeholder="Başlık"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<textarea
className="w-full rounded border bg-background p-2 text-sm"
rows={2}
placeholder="Brief — içerik ne anlatacak?"
value={brief}
onChange={(e) => setBrief(e.target.value)}
/>
<input
className="w-full rounded border bg-background px-2 py-1 text-sm"
placeholder="Açı (opsiyonel)"
value={angle}
onChange={(e) => setAngle(e.target.value)}
/>
<input
className="w-full rounded border bg-background px-2 py-1 text-sm"
placeholder="Anahtar kelimeler (virgülle)"
value={keywords}
onChange={(e) => setKeywords(e.target.value)}
/>
<div className="flex flex-wrap items-center gap-3">
{CHANNELS.map((c) => (
<label key={c.value} className="flex items-center gap-1 text-xs">
<input
type="checkbox"
checked={channels.includes(c.value)}
onChange={() => toggleChannel(c.value)}
/>
{c.label}
</label>
))}
<Button size="sm" disabled={pending || !title.trim()} onClick={submitTopic}>
Ekle
</Button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,174 @@
import { PanelShell } from "@/components/panel-shell";
import { prisma } from "@/lib/db";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
export const dynamic = "force-dynamic";
const CONTENT_FILTER = { promptTag: { startsWith: "content_" } };
function startOfDayUtc(d: Date): Date {
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
}
function startOfMonthUtc(d: Date): Date {
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1));
}
export default async function ContentCostDashboard() {
const now = new Date();
const [todayAgg, monthAgg, byPrompt, recentErrors, budgetSettings, draftCount, topicCount] =
await Promise.all([
prisma.costLedger.aggregate({
where: { ...CONTENT_FILTER, createdAt: { gte: startOfDayUtc(now) } },
_sum: { costTotalUsd: true },
_count: true,
}),
prisma.costLedger.aggregate({
where: { ...CONTENT_FILTER, createdAt: { gte: startOfMonthUtc(now) } },
_sum: { costTotalUsd: true },
_count: true,
}),
prisma.costLedger.groupBy({
by: ["promptTag"],
where: { ...CONTENT_FILTER, createdAt: { gte: startOfMonthUtc(now) } },
_sum: { costTotalUsd: true },
_count: true,
}),
prisma.costLedger.findMany({
where: { ...CONTENT_FILTER, errorCode: { not: null } },
orderBy: { createdAt: "desc" },
take: 10,
}),
prisma.budgetSetting.findMany({ where: { projectKey: null } }),
prisma.contentDraft.count({ where: { projectKey: "sase" } }),
prisma.contentTopic.count({ where: { projectKey: "sase" } }),
]);
const limits: Record<string, number> = {};
for (const b of budgetSettings) {
if (typeof b.settingValue === "number") limits[b.settingKey] = b.settingValue;
}
const monthlyCap = limits.content_monthly_hard_cap_usd ?? 15;
const dailyHardCap = limits.content_daily_hard_cap_usd ?? 2;
const today = Number(todayAgg._sum.costTotalUsd ?? 0);
const month = Number(monthAgg._sum.costTotalUsd ?? 0);
const monthCalls = Number(monthAgg._count ?? 0);
const avgPerCall = monthCalls > 0 ? month / monthCalls : 0;
const todayPct = Math.min(100, (today / dailyHardCap) * 100);
const monthPct = Math.min(100, (month / monthlyCap) * 100);
return (
<PanelShell title="Content · maliyet">
<p className="text-sm text-muted-foreground">
İçerik üretimi DeepSeek harcaması (insight bütçesinden ayrı).{" "}
<a href="/content" className="underline">Kuyruk</a> ·{" "}
<a href="/insights/settings/budgets" className="underline">Bütçe ayarları</a>
</p>
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardDescription>Bugün</CardDescription>
<CardTitle className="text-2xl">${today.toFixed(4)}</CardTitle>
</CardHeader>
<CardContent className="text-xs text-muted-foreground">
{todayPct.toFixed(0)}% / günlük sert sınır ${dailyHardCap}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Bu ay</CardDescription>
<CardTitle className="text-2xl">${month.toFixed(2)}</CardTitle>
</CardHeader>
<CardContent className="text-xs text-muted-foreground">
{monthPct.toFixed(0)}% / aylık sınır ${monthlyCap}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Çağrı başı ort. (ay)</CardDescription>
<CardTitle className="text-2xl">${avgPerCall.toFixed(4)}</CardTitle>
</CardHeader>
<CardContent className="text-xs text-muted-foreground">{monthCalls} çağrı</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Üretilen</CardDescription>
<CardTitle className="text-2xl">{draftCount}</CardTitle>
</CardHeader>
<CardContent className="text-xs text-muted-foreground">{topicCount} konu · taslak</CardContent>
</Card>
</div>
<h2 className="mt-2 text-sm font-medium text-muted-foreground">Prompt başına (bu ay)</h2>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Prompt</TableHead>
<TableHead className="w-[120px]">Çağrı</TableHead>
<TableHead className="w-[120px]">Maliyet</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{byPrompt.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="text-center text-xs text-muted-foreground">
Henüz içerik harcaması yok.
</TableCell>
</TableRow>
) : (
byPrompt.map((p) => (
<TableRow key={p.promptTag ?? "—"}>
<TableCell className="text-xs">{p.promptTag ?? "—"}</TableCell>
<TableCell className="font-mono text-xs">{p._count}</TableCell>
<TableCell className="font-mono text-xs">${Number(p._sum.costTotalUsd ?? 0).toFixed(4)}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{recentErrors.length > 0 && (
<>
<h2 className="mt-2 text-sm font-medium text-muted-foreground">Son hatalar</h2>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Zaman</TableHead>
<TableHead>Model</TableHead>
<TableHead>Prompt</TableHead>
<TableHead>Hata</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{recentErrors.map((r) => (
<TableRow key={r.id}>
<TableCell className="text-xs text-muted-foreground">
{r.createdAt.toISOString().slice(0, 19).replace("T", " ")}
</TableCell>
<TableCell className="text-xs">{r.model}</TableCell>
<TableCell className="text-xs">{r.promptTag ?? "—"}</TableCell>
<TableCell><Badge variant="destructive">{r.errorCode}</Badge></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</>
)}
</PanelShell>
);
}

View File

@@ -0,0 +1,113 @@
import { PanelShell } from "@/components/panel-shell";
import { prisma } from "@/lib/db";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ContentControls } from "./_controls";
export const dynamic = "force-dynamic";
const PROJECT_KEY = "sase";
function statusVariant(status: string): "default" | "secondary" | "outline" | "destructive" {
switch (status) {
case "drafted":
return "default";
case "generating":
return "secondary";
case "archived":
return "destructive";
default:
return "outline";
}
}
export default async function ContentInbox() {
const [topics, paused, queuedCount] = await Promise.all([
prisma.contentTopic.findMany({
where: { projectKey: PROJECT_KEY, status: { not: "archived" } },
orderBy: { createdAt: "desc" },
take: 100,
include: { drafts: { select: { status: true, channel: true } } },
}),
prisma.budgetSetting.findFirst({ where: { projectKey: null, settingKey: "content_paused" } }),
prisma.contentTopic.count({
where: { projectKey: PROJECT_KEY, status: { in: ["queued", "generating"] } },
}),
]);
const isPaused = paused?.settingValue === true;
return (
<PanelShell title="Content · konu kuyruğu">
<p className="text-sm text-muted-foreground">
Sase.tr için otomatik içerik üretimi. Konular LLM ile üretilir, kanal taslakları panelde birikir, sen
incele/düzenle/onayla.{" "}
<a href="/content/costs" className="underline">Maliyet</a>
{isPaused && (
<>
{" · "}
<Badge variant="destructive">content_paused</Badge>
</>
)}
</p>
<ContentControls queuedCount={queuedCount} />
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Konu</TableHead>
<TableHead className="w-[110px]">Durum</TableHead>
<TableHead className="w-[90px]">Kaynak</TableHead>
<TableHead className="w-[160px]">Kanallar</TableHead>
<TableHead className="w-[90px]">Taslak</TableHead>
<TableHead className="w-[110px]">Oluşturma</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{topics.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-xs text-muted-foreground">
Henüz konu yok. Yukarıdan otomatik üret ya da elle ekle.
</TableCell>
</TableRow>
) : (
topics.map((t) => {
const approved = t.drafts.filter((d) => d.status === "approved").length;
return (
<TableRow key={t.id}>
<TableCell>
<a href={`/content/t/${t.id}`} className="font-medium underline-offset-2 hover:underline">
{t.title}
</a>
</TableCell>
<TableCell>
<Badge variant={statusVariant(t.status)}>{t.status}</Badge>
</TableCell>
<TableCell className="text-xs text-muted-foreground">{t.source}</TableCell>
<TableCell className="text-xs">{(t.channels as string[]).join(", ")}</TableCell>
<TableCell className="font-mono text-xs">
{t.drafts.length}
{approved > 0 && <span className="text-muted-foreground"> ({approved})</span>}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{t.createdAt.toISOString().slice(0, 10)}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
</PanelShell>
);
}

View File

@@ -0,0 +1,191 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { saveDraftEdits, setDraftStatus } from "../../_actions";
type Json = unknown;
type Props = {
draftId: string;
channel: string;
status: string;
bodyJson: Json;
founderEdits: Json;
model: string | null;
costUsd: number;
publishedUrl: string | null;
publishError: string | null;
};
function asRecord(v: Json): Record<string, unknown> {
return v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
}
function str(v: unknown): string {
return typeof v === "string" ? v : v == null ? "" : String(v);
}
function arr(v: unknown): string[] {
return Array.isArray(v) ? v.map(str) : [];
}
function ChannelPreview({ channel, content }: { channel: string; content: Record<string, unknown> }) {
if (channel === "blog") {
return (
<div className="space-y-2">
<div className="text-base font-semibold">{str(content.title)}</div>
<div className="text-xs text-muted-foreground">/{str(content.slug)}</div>
<div className="text-xs italic text-muted-foreground">{str(content.meta_description)}</div>
<pre className="whitespace-pre-wrap rounded bg-muted/40 p-2 text-sm">{str(content.body_markdown)}</pre>
{content.cta != null && <div className="text-sm">CTA: {str(content.cta)}</div>}
<div className="flex flex-wrap gap-1">
{arr(content.tags).map((t) => (
<Badge key={t} variant="outline" className="text-xs">{t}</Badge>
))}
</div>
</div>
);
}
if (channel === "x") {
const tweets = arr(content.tweets);
return (
<div className="space-y-2">
{tweets.map((t, i) => (
<div key={i} className="rounded border p-2 text-sm">
<span className="mr-2 text-xs text-muted-foreground">{i + 1}/{tweets.length}</span>
{t}
<span className="ml-2 text-xs text-muted-foreground">({t.length})</span>
</div>
))}
<div className="flex flex-wrap gap-1">
{arr(content.hashtags).map((h) => (
<Badge key={h} variant="outline" className="text-xs">{h}</Badge>
))}
</div>
</div>
);
}
// linkedin / instagram — body or caption + hashtags
const text = channel === "instagram" ? str(content.caption) : str(content.body);
return (
<div className="space-y-2">
<pre className="whitespace-pre-wrap rounded bg-muted/40 p-2 text-sm">{text}</pre>
{content.cta != null && <div className="text-sm">CTA: {str(content.cta)}</div>}
{content.image_prompt != null && (
<div className="text-xs text-muted-foreground">image_prompt: {str(content.image_prompt)}</div>
)}
<div className="flex flex-wrap gap-1">
{arr(content.hashtags).map((h) => (
<Badge key={h} variant="outline" className="text-xs">{h}</Badge>
))}
</div>
</div>
);
}
const STATUS_VARIANT: Record<string, "default" | "secondary" | "outline" | "destructive"> = {
approved: "default",
draft: "outline",
rejected: "destructive",
published: "default",
publishing: "secondary",
failed: "destructive",
};
export function DraftCard(props: Props) {
const router = useRouter();
const [pending, start] = useTransition();
const [flash, setFlash] = useState<string | null>(null);
const [editing, setEditing] = useState(false);
const [editsText, setEditsText] = useState(
props.founderEdits ? JSON.stringify(props.founderEdits, null, 2) : "",
);
const base = asRecord(props.bodyJson);
const edits = asRecord(props.founderEdits);
const effective = { ...base, ...edits };
const locked = ["published", "publishing"].includes(props.status);
const fire = (fn: () => Promise<unknown>, label: string) =>
start(async () => {
try {
await fn();
setFlash(label);
setTimeout(() => setFlash(null), 2500);
router.refresh();
} catch (e) {
setFlash(`hata: ${(e as Error).message}`);
}
});
return (
<div className="space-y-3 rounded-md border p-3">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-sm font-medium uppercase">{props.channel}</span>
<Badge variant={STATUS_VARIANT[props.status] ?? "outline"}>{props.status}</Badge>
{Object.keys(edits).length > 0 && <Badge variant="secondary" className="text-xs">düzenlendi</Badge>}
<span className="ml-auto text-xs text-muted-foreground">
{props.model ?? "—"} · ${props.costUsd.toFixed(4)}
</span>
</div>
{props.publishedUrl && (
<div className="text-xs">
Yayınlandı: <a href={props.publishedUrl} className="underline" target="_blank" rel="noreferrer">{props.publishedUrl}</a>
</div>
)}
{props.publishError && <div className="text-xs text-destructive">Yayın hatası: {props.publishError}</div>}
<ChannelPreview channel={props.channel} content={effective} />
{editing && (
<div className="space-y-1">
<div className="text-xs text-muted-foreground">
founder edits (JSON object sadece değiştirmek istediğin alanları yaz, üzerine biner)
</div>
<textarea
className="w-full rounded border bg-background p-2 font-mono text-xs"
rows={8}
value={editsText}
onChange={(e) => setEditsText(e.target.value)}
placeholder={'{\n "title": "..."\n}'}
/>
<Button size="sm" variant="outline" disabled={pending} onClick={() => fire(() => saveDraftEdits(props.draftId, editsText), "düzenleme kaydedildi")}>
Düzenlemeyi kaydet
</Button>
</div>
)}
{!locked && (
<div className="flex flex-wrap items-center gap-2 border-t pt-2">
<Button size="sm" variant="outline" onClick={() => setEditing((v) => !v)}>
{editing ? "Düzenlemeyi gizle" : "Düzenle"}
</Button>
<Button
size="sm"
disabled={pending || props.status === "approved"}
onClick={() => fire(() => setDraftStatus(props.draftId, "approved"), "onaylandı")}
>
Onayla
</Button>
{props.status !== "draft" && (
<Button size="sm" variant="outline" disabled={pending} onClick={() => fire(() => setDraftStatus(props.draftId, "draft"), "taslağa alındı")}>
Taslağa al
</Button>
)}
<Button
size="sm"
variant="destructive"
disabled={pending || props.status === "rejected"}
onClick={() => fire(() => setDraftStatus(props.draftId, "rejected"), "reddedildi")}
>
Reddet
</Button>
{flash && <span className="text-xs text-muted-foreground">{flash}</span>}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,43 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { archiveTopic, requeueTopic, enqueueContentGeneration } from "../../_actions";
export function TopicActions({ topicId, status }: { topicId: string; status: string }) {
const router = useRouter();
const [pending, start] = useTransition();
const [flash, setFlash] = useState<string | null>(null);
const fire = (fn: () => Promise<unknown>, label: string) =>
start(async () => {
try {
await fn();
setFlash(label);
setTimeout(() => setFlash(null), 2500);
router.refresh();
} catch (e) {
setFlash(`hata: ${(e as Error).message}`);
}
});
return (
<div className="flex flex-wrap items-center gap-2 pt-1">
{status !== "queued" && (
<Button size="sm" variant="outline" disabled={pending} onClick={() => fire(() => requeueTopic(topicId), "kuyruğa alındı")}>
Yeniden kuyruğa al
</Button>
)}
<Button size="sm" variant="outline" disabled={pending} onClick={() => fire(() => enqueueContentGeneration(), "üretim kuyruğa alındı")}>
Taslakları üret
</Button>
{status !== "archived" && (
<Button size="sm" variant="destructive" disabled={pending} onClick={() => fire(() => archiveTopic(topicId), "arşivlendi")}>
Arşivle
</Button>
)}
{flash && <span className="text-xs text-muted-foreground">{flash}</span>}
</div>
);
}

View File

@@ -0,0 +1,76 @@
import { notFound } from "next/navigation";
import { PanelShell } from "@/components/panel-shell";
import { prisma } from "@/lib/db";
import { Badge } from "@/components/ui/badge";
import { DraftCard } from "./_draft-card";
import { TopicActions } from "./_topic-actions";
export const dynamic = "force-dynamic";
const CHANNEL_ORDER = ["blog", "linkedin", "x", "instagram"];
export default async function TopicDetail({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const topic = await prisma.contentTopic.findUnique({
where: { id },
include: { drafts: true },
});
if (!topic) notFound();
const drafts = [...topic.drafts].sort(
(a, b) => CHANNEL_ORDER.indexOf(a.channel) - CHANNEL_ORDER.indexOf(b.channel),
);
const totalCost = topic.drafts.reduce((s, d) => s + d.sourceCostUsd, topic.sourceCostUsd);
return (
<PanelShell title="Content · konu">
<p className="text-sm text-muted-foreground">
<a href="/content" className="underline"> Kuyruk</a>
</p>
<div className="space-y-2 rounded-md border p-3">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-lg font-semibold">{topic.title}</h1>
<Badge variant="outline">{topic.status}</Badge>
<Badge variant="secondary">{topic.source}</Badge>
</div>
{topic.brief && <p className="text-sm">{topic.brief}</p>}
{topic.angle && <p className="text-xs text-muted-foreground">ı: {topic.angle}</p>}
<div className="flex flex-wrap gap-1">
{(topic.keywords as string[]).map((k) => (
<Badge key={k} variant="outline" className="text-xs">{k}</Badge>
))}
</div>
<div className="text-xs text-muted-foreground">
Kanallar: {(topic.channels as string[]).join(", ")} · Maliyet: ${totalCost.toFixed(4)} ·{" "}
{topic.sourceModel ?? "—"}
</div>
<TopicActions topicId={topic.id} status={topic.status} />
</div>
{drafts.length === 0 ? (
<p className="text-sm text-muted-foreground">
Henüz taslak üretilmemiş. Konu kuyruktaysa <span className="font-mono">content-generate</span> job'u
(10dk) ya da kuyruk sayfasındaki Taslakları üret bunu işler.
</p>
) : (
<div className="space-y-4">
{drafts.map((d) => (
<DraftCard
key={d.id}
draftId={d.id}
channel={d.channel}
status={d.status}
bodyJson={d.bodyJson}
founderEdits={d.founderEdits}
model={d.sourceModel}
costUsd={d.sourceCostUsd}
publishedUrl={d.publishedUrl}
publishError={d.publishError}
/>
))}
</div>
)}
</PanelShell>
);
}

View File

@@ -318,6 +318,12 @@ export async function updateBudgetSetting(key: string, value: number | boolean)
"min_score_for_analysis",
"cache_ttl_hours",
"analysis_paused",
// Content generation (separate envelope) — see lib/content-budget.ts
"content_monthly_hard_cap_usd",
"content_daily_soft_cap_usd",
"content_daily_hard_cap_usd",
"content_per_call_max_usd",
"content_paused",
]);
if (!allowed.has(key)) throw new Error("bad setting key");
const existing = await prisma.budgetSetting.findFirst({
@@ -342,4 +348,8 @@ export async function updateBudgetSetting(key: string, value: number | boolean)
revalidatePath("/insights/settings/budgets");
revalidatePath("/insights/costs");
revalidatePath("/insights");
if (key.startsWith("content_")) {
revalidatePath("/content/costs");
revalidatePath("/content");
}
}

View File

@@ -53,6 +53,41 @@ const SCHEMA: Array<{ key: string; label: string; unit?: string; help?: string;
help: "Kill switch — no LLM calls until cleared. Pipeline still ingests/tags/compresses.",
default: false,
},
// ---- Content generation (separate envelope from insight analysis) ----
{
key: "content_monthly_hard_cap_usd",
label: "Content · monthly hard cap",
unit: "USD/month",
help: "Content generation halts when its month-to-date spend reaches this.",
default: 15,
},
{
key: "content_daily_soft_cap_usd",
label: "Content · daily soft cap",
unit: "USD/day",
help: "Above this, content pro tier downgrades to flash for the rest of today.",
default: 1,
},
{
key: "content_daily_hard_cap_usd",
label: "Content · daily hard cap",
unit: "USD/day",
help: "Content generation paused above this until UTC midnight.",
default: 2,
},
{
key: "content_per_call_max_usd",
label: "Content · per-call max",
unit: "USD",
help: "Single content LLM call ceiling. Currently warn-only.",
default: 0.3,
},
{
key: "content_paused",
label: "Pause content generation",
help: "Kill switch for content topics + drafts. Independent of analysis_paused.",
default: false,
},
];
export default async function BudgetsSettingsPage() {

View File

@@ -7,6 +7,7 @@ import {
FolderIcon,
LayoutDashboardIcon,
LightbulbIcon,
PenLineIcon,
ScrollTextIcon,
Settings2Icon,
TerminalIcon,
@@ -30,6 +31,7 @@ const navMain = [
{ title: "Projects", url: "/projects", icon: <FolderIcon /> },
{ title: "Operations", url: "/operations", icon: <TerminalIcon /> },
{ title: "Insights", url: "/insights", icon: <LightbulbIcon /> },
{ title: "Content", url: "/content", icon: <PenLineIcon /> },
{ title: "Events", url: "/events", icon: <ActivityIcon /> },
{ title: "Audit", url: "/audit", icon: <ScrollTextIcon /> },
];

View File

@@ -18,6 +18,7 @@ import {
LayoutDashboardIcon,
LightbulbIcon,
LogOutIcon,
PenLineIcon,
ScrollTextIcon,
Settings2Icon,
SlidersHorizontalIcon,
@@ -94,6 +95,17 @@ export function CommandPalette({ projects }: { projects: ProjectLite[] }) {
<CommandSeparator />
<CommandGroup heading="Content">
<CommandItem keywords={["content","icerik","konu","kuyruk","queue"]} onSelect={() => go("/content")}>
<PenLineIcon /> İçerik kuyruğu
</CommandItem>
<CommandItem keywords={["content","cost","maliyet","spend"]} onSelect={() => go("/content/costs")}>
<CoinsIcon /> İçerik maliyeti
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Projects">
{projects.map((p) => (
<CommandItem

View File

@@ -3,6 +3,7 @@ import IORedis from "ioredis";
const url = process.env.REDIS_URL;
let _queue: Queue | null = null;
let _contentQueue: Queue | null = null;
export function pipelineQueue(): Queue {
if (_queue) return _queue;
@@ -11,3 +12,11 @@ export function pipelineQueue(): Queue {
_queue = new Queue("insight-pipeline", { connection });
return _queue;
}
export function contentQueue(): Queue {
if (_contentQueue) return _contentQueue;
if (!url) throw new Error("REDIS_URL not set");
const connection = new IORedis(url, { maxRetriesPerRequest: null });
_contentQueue = new Queue("content-pipeline", { connection });
return _contentQueue;
}