Files
sp/apps/web/src/app/insights/_actions.ts
Semih fdce3f6bd0 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>
2026-05-24 00:11:05 +03:00

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");
}
}