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>
356 lines
10 KiB
TypeScript
356 lines
10 KiB
TypeScript
"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 { createIssue, buildIssueBody } from "@/lib/gitea";
|
|
import { pipelineQueue } from "@/lib/queue";
|
|
import { alertIssueCreated } from "@/lib/telegram";
|
|
|
|
const ALLOWED_STATUS = new Set([
|
|
"new",
|
|
"triaged",
|
|
"in_backlog",
|
|
"in_progress",
|
|
"shipped",
|
|
"validating",
|
|
"validated",
|
|
"deferred",
|
|
"dismissed",
|
|
"duplicate",
|
|
"regressed",
|
|
]);
|
|
|
|
async function requireSession() {
|
|
const session = await auth.api.getSession({ headers: await headers() });
|
|
if (!session) throw new Error("unauthenticated");
|
|
return session;
|
|
}
|
|
|
|
export async function setStatus(insightId: string, status: string) {
|
|
await requireSession();
|
|
if (!ALLOWED_STATUS.has(status)) throw new Error(`bad status: ${status}`);
|
|
await prisma.insight.update({
|
|
where: { id: insightId },
|
|
data: { status },
|
|
});
|
|
await writeAudit({
|
|
endpoint: `/insights/${insightId}/status`,
|
|
method: "POST",
|
|
requestPayload: { status },
|
|
responseStatus: 200,
|
|
});
|
|
revalidatePath("/insights");
|
|
revalidatePath(`/insights/i/${insightId}`);
|
|
}
|
|
|
|
export async function setFounderNotes(insightId: string, notes: string) {
|
|
await requireSession();
|
|
await prisma.insight.update({
|
|
where: { id: insightId },
|
|
data: { founderNotes: notes.slice(0, 4000) || null },
|
|
});
|
|
await writeAudit({
|
|
endpoint: `/insights/${insightId}/notes`,
|
|
method: "POST",
|
|
requestPayload: { length: notes.length },
|
|
responseStatus: 200,
|
|
});
|
|
revalidatePath(`/insights/i/${insightId}`);
|
|
}
|
|
|
|
export async function setFounderPriority(insightId: string, priority: number | null) {
|
|
await requireSession();
|
|
await prisma.insight.update({
|
|
where: { id: insightId },
|
|
data: { founderPriority: priority },
|
|
});
|
|
await writeAudit({
|
|
endpoint: `/insights/${insightId}/priority`,
|
|
method: "POST",
|
|
requestPayload: { priority },
|
|
responseStatus: 200,
|
|
});
|
|
revalidatePath("/insights");
|
|
revalidatePath(`/insights/i/${insightId}`);
|
|
}
|
|
|
|
export async function setSeverityOverride(insightId: string, severity: string | null) {
|
|
await requireSession();
|
|
if (severity !== null && !["P0", "P1", "P2", "P3", "INFO"].includes(severity)) {
|
|
throw new Error("bad severity");
|
|
}
|
|
await prisma.insight.update({
|
|
where: { id: insightId },
|
|
data: { founderSeverityOverride: severity, severity: severity ?? undefined },
|
|
});
|
|
await writeAudit({
|
|
endpoint: `/insights/${insightId}/severity`,
|
|
method: "POST",
|
|
requestPayload: { severity },
|
|
responseStatus: 200,
|
|
});
|
|
revalidatePath("/insights");
|
|
revalidatePath(`/insights/i/${insightId}`);
|
|
}
|
|
|
|
export async function bulkSetStatus(insightIds: string[], status: string) {
|
|
await requireSession();
|
|
if (!ALLOWED_STATUS.has(status)) throw new Error("bad status");
|
|
const ids = insightIds.filter(Boolean).slice(0, 200);
|
|
await prisma.insight.updateMany({
|
|
where: { id: { in: ids } },
|
|
data: { status },
|
|
});
|
|
await writeAudit({
|
|
endpoint: `/insights/bulk/status`,
|
|
method: "POST",
|
|
requestPayload: { count: ids.length, status },
|
|
responseStatus: 200,
|
|
});
|
|
revalidatePath("/insights");
|
|
}
|
|
|
|
export async function createGithubIssueForInsight(
|
|
insightId: string,
|
|
opts?: { titleOverride?: string; labelsExtra?: string[] },
|
|
): Promise<{ url: string; number: number }> {
|
|
await requireSession();
|
|
const insight = await prisma.insight.findUnique({ where: { id: insightId } });
|
|
if (!insight) throw new Error("insight not found");
|
|
if (insight.githubIssueUrl) throw new Error("issue already exists");
|
|
|
|
const panelUrl = process.env.BETTER_AUTH_URL ?? "https://sp.semih.ai";
|
|
const title = (opts?.titleOverride ?? insight.title).slice(0, 256);
|
|
const body = buildIssueBody({
|
|
insightId: insight.id,
|
|
panelUrl,
|
|
type: insight.type,
|
|
severity: insight.severity,
|
|
occurrenceCount: insight.occurrenceCount,
|
|
uniqueUserCount: insight.uniqueUserCount,
|
|
firstSeenAt: insight.firstSeenAt,
|
|
lastSeenAt: insight.lastSeenAt,
|
|
confidence: insight.confidence,
|
|
model: insight.sourceModel,
|
|
body: insight.body as Record<string, unknown>,
|
|
relatedSessionIds: insight.relatedSessionIds,
|
|
});
|
|
const labels = [
|
|
"insight-driven",
|
|
`severity-${insight.severity}`,
|
|
`type-${insight.type}`,
|
|
`${insight.projectKey}-pilot`,
|
|
...(opts?.labelsExtra ?? []),
|
|
];
|
|
|
|
const issue = await createIssue(insight.projectKey, { title, body, labels });
|
|
|
|
await prisma.insight.update({
|
|
where: { id: insightId },
|
|
data: {
|
|
githubIssueUrl: issue.html_url,
|
|
githubIssueId: BigInt(issue.id),
|
|
githubIssueNumber: issue.number,
|
|
githubIssueState: issue.state,
|
|
status: "in_backlog",
|
|
},
|
|
});
|
|
|
|
await writeAudit({
|
|
endpoint: `/insights/${insightId}/github-issue`,
|
|
method: "POST",
|
|
requestPayload: { issueNumber: issue.number, repo: process.env[`GITEA_REPO_${insight.projectKey.toUpperCase()}`] },
|
|
responseStatus: 200,
|
|
});
|
|
|
|
// Telegram notification (fire-and-forget)
|
|
void alertIssueCreated({
|
|
insightId: insight.id,
|
|
insightTitle: insight.title,
|
|
severity: insight.severity,
|
|
type: insight.type,
|
|
issueNumber: issue.number,
|
|
issueUrl: issue.html_url,
|
|
panelUrl,
|
|
});
|
|
|
|
revalidatePath("/insights");
|
|
revalidatePath(`/insights/i/${insightId}`);
|
|
return { url: issue.html_url, number: issue.number };
|
|
}
|
|
|
|
export type PromptEditInput = {
|
|
tag: string;
|
|
systemPrompt: string;
|
|
userPromptTemplate: string;
|
|
outputSchemaJson: object;
|
|
modelTier: "flash" | "pro";
|
|
maxOutputTokens: number;
|
|
temperature: number;
|
|
name: string;
|
|
};
|
|
|
|
// Create a new version of a prompt and deactivate the old one.
|
|
export async function publishPromptVersion(input: PromptEditInput): Promise<{ id: string; version: number }> {
|
|
await requireSession();
|
|
const latest = await prisma.promptTemplate.findFirst({
|
|
where: { tag: input.tag },
|
|
orderBy: { version: "desc" },
|
|
});
|
|
const nextVersion = (latest?.version ?? 0) + 1;
|
|
|
|
// Deactivate all previous versions of this tag
|
|
await prisma.promptTemplate.updateMany({
|
|
where: { tag: input.tag, active: true },
|
|
data: { active: false },
|
|
});
|
|
|
|
const created = await prisma.promptTemplate.create({
|
|
data: {
|
|
tag: input.tag,
|
|
version: nextVersion,
|
|
name: input.name || `${input.tag} v${nextVersion}`,
|
|
systemPrompt: input.systemPrompt,
|
|
userPromptTemplate: input.userPromptTemplate,
|
|
outputSchemaJson: input.outputSchemaJson as object,
|
|
modelTier: input.modelTier,
|
|
maxOutputTokens: input.maxOutputTokens,
|
|
temperature: input.temperature,
|
|
active: true,
|
|
},
|
|
});
|
|
|
|
await writeAudit({
|
|
endpoint: `/insights/prompts/${input.tag}/publish`,
|
|
method: "POST",
|
|
requestPayload: { version: nextVersion },
|
|
responseStatus: 200,
|
|
});
|
|
revalidatePath("/insights/settings/prompts");
|
|
revalidatePath(`/insights/settings/prompts/${created.id}`);
|
|
return { id: created.id, version: nextVersion };
|
|
}
|
|
|
|
export async function setPromptActive(id: string, active: boolean) {
|
|
await requireSession();
|
|
const p = await prisma.promptTemplate.findUnique({ where: { id } });
|
|
if (!p) throw new Error("prompt not found");
|
|
if (active) {
|
|
// Deactivate other versions of this tag
|
|
await prisma.promptTemplate.updateMany({
|
|
where: { tag: p.tag, active: true, id: { not: id } },
|
|
data: { active: false },
|
|
});
|
|
}
|
|
await prisma.promptTemplate.update({ where: { id }, data: { active } });
|
|
await writeAudit({
|
|
endpoint: `/insights/prompts/${p.tag}/active`,
|
|
method: "POST",
|
|
requestPayload: { active, version: p.version },
|
|
responseStatus: 200,
|
|
});
|
|
revalidatePath("/insights/settings/prompts");
|
|
revalidatePath(`/insights/settings/prompts/${id}`);
|
|
}
|
|
|
|
export async function createEvalSet(input: {
|
|
promptTag: string;
|
|
name: string;
|
|
description?: string;
|
|
casesJson: string;
|
|
}): Promise<{ id: string }> {
|
|
await requireSession();
|
|
let cases: unknown;
|
|
try {
|
|
cases = JSON.parse(input.casesJson);
|
|
} catch (e) {
|
|
throw new Error(`cases not valid JSON: ${(e as Error).message}`);
|
|
}
|
|
if (!Array.isArray(cases)) throw new Error("cases must be an array");
|
|
const created = await prisma.evalSet.create({
|
|
data: {
|
|
promptTag: input.promptTag,
|
|
name: input.name,
|
|
description: input.description ?? null,
|
|
cases: cases as object,
|
|
},
|
|
});
|
|
await writeAudit({
|
|
endpoint: `/insights/eval-sets`,
|
|
method: "POST",
|
|
requestPayload: { tag: input.promptTag, cases: (cases as unknown[]).length },
|
|
responseStatus: 200,
|
|
});
|
|
revalidatePath("/insights/settings/eval-sets");
|
|
return { id: created.id };
|
|
}
|
|
|
|
export async function triggerEvalRun(
|
|
evalSetId: string,
|
|
promptVersion?: number,
|
|
): Promise<{ jobId: string }> {
|
|
await requireSession();
|
|
const queue = pipelineQueue();
|
|
const job = await queue.add(
|
|
"eval-run",
|
|
{ evalSetId, promptVersion: promptVersion ?? null },
|
|
{ removeOnComplete: 50, removeOnFail: 25 },
|
|
);
|
|
await writeAudit({
|
|
endpoint: `/insights/eval-sets/${evalSetId}/run`,
|
|
method: "POST",
|
|
requestPayload: { promptVersion },
|
|
responseStatus: 202,
|
|
});
|
|
return { jobId: String(job.id ?? "unknown") };
|
|
}
|
|
|
|
export async function updateBudgetSetting(key: string, value: number | boolean) {
|
|
await requireSession();
|
|
const allowed = new Set([
|
|
"monthly_hard_cap_usd",
|
|
"daily_soft_cap_usd",
|
|
"daily_hard_cap_usd",
|
|
"per_call_max_usd",
|
|
"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({
|
|
where: { projectKey: null, settingKey: key },
|
|
});
|
|
if (existing) {
|
|
await prisma.budgetSetting.update({
|
|
where: { id: existing.id },
|
|
data: { settingValue: value as unknown as object },
|
|
});
|
|
} else {
|
|
await prisma.budgetSetting.create({
|
|
data: { projectKey: null, settingKey: key, settingValue: value as unknown as object },
|
|
});
|
|
}
|
|
await writeAudit({
|
|
endpoint: `/insights/budget/${key}`,
|
|
method: "POST",
|
|
requestPayload: { value },
|
|
responseStatus: 200,
|
|
});
|
|
revalidatePath("/insights/settings/budgets");
|
|
revalidatePath("/insights/costs");
|
|
revalidatePath("/insights");
|
|
if (key.startsWith("content_")) {
|
|
revalidatePath("/content/costs");
|
|
revalidatePath("/content");
|
|
}
|
|
}
|