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>
448 lines
13 KiB
Plaintext
448 lines
13 KiB
Plaintext
generator client {
|
|
provider = "prisma-client-js"
|
|
}
|
|
|
|
datasource db {
|
|
provider = "postgresql"
|
|
url = env("DATABASE_URL_PANEL")
|
|
}
|
|
|
|
model User {
|
|
id String @id
|
|
name String
|
|
email String @unique
|
|
emailVerified Boolean @default(false)
|
|
image String?
|
|
twoFactorEnabled Boolean @default(false)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
sessions Session[]
|
|
accounts Account[]
|
|
twoFactors TwoFactor[]
|
|
}
|
|
|
|
model Session {
|
|
id String @id
|
|
userId String
|
|
token String @unique
|
|
expiresAt DateTime
|
|
ipAddress String?
|
|
userAgent String?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
}
|
|
|
|
model Account {
|
|
id String @id
|
|
userId String
|
|
accountId String
|
|
providerId String
|
|
accessToken String?
|
|
refreshToken String?
|
|
accessTokenExpiresAt DateTime?
|
|
refreshTokenExpiresAt DateTime?
|
|
scope String?
|
|
idToken String?
|
|
password String?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
}
|
|
|
|
model Verification {
|
|
id String @id
|
|
identifier String
|
|
value String
|
|
expiresAt DateTime
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
}
|
|
|
|
model TwoFactor {
|
|
id String @id
|
|
userId String
|
|
secret String
|
|
backupCodes String
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
}
|
|
|
|
model Project {
|
|
id String @id @default(cuid())
|
|
key String @unique
|
|
name String
|
|
description String?
|
|
/// planned | wired | active
|
|
status String @default("planned")
|
|
active Boolean @default(false)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
}
|
|
|
|
model Event {
|
|
id String @id @default(cuid())
|
|
streamId String @unique
|
|
projectKey String
|
|
eventType String
|
|
version Int @default(1)
|
|
payload Json
|
|
occurredAt DateTime
|
|
receivedAt DateTime @default(now())
|
|
|
|
@@index([projectKey, occurredAt])
|
|
@@index([eventType])
|
|
}
|
|
|
|
model AuditLog {
|
|
id String @id @default(cuid())
|
|
actorUserId String?
|
|
projectKey String?
|
|
endpoint String
|
|
method String
|
|
requestHash String?
|
|
responseStatus Int?
|
|
durationMs Int?
|
|
sourceIp String?
|
|
userAgent String?
|
|
createdAt DateTime @default(now())
|
|
|
|
@@index([createdAt])
|
|
@@index([actorUserId])
|
|
@@index([projectKey])
|
|
}
|
|
|
|
// ---------- Phase 6a: Behavioral Insight Pipeline ----------
|
|
|
|
model SessionMeta {
|
|
id String @id
|
|
projectKey String
|
|
userIdHash String?
|
|
posthogDistinctId String?
|
|
isAuthenticated Boolean @default(false)
|
|
subscriptionTier String?
|
|
groupKey String?
|
|
startedAt DateTime
|
|
durationMs Int
|
|
pageviewCount Int @default(0)
|
|
clickCount Int @default(0)
|
|
errorCount Int @default(0)
|
|
rageClickCount Int @default(0)
|
|
deadClickCount Int @default(0)
|
|
network5xxCount Int @default(0)
|
|
network4xxCount Int @default(0)
|
|
startUrl String?
|
|
promotionReasons String[]
|
|
tags String[]
|
|
severity String?
|
|
score Int?
|
|
customEventCount Int @default(0)
|
|
rawMetadataUrl String?
|
|
status String @default("pending_signal")
|
|
fingerprint String?
|
|
processedAt DateTime?
|
|
createdAt DateTime @default(now())
|
|
|
|
compressed CompressedSession?
|
|
|
|
@@index([status, createdAt])
|
|
@@index([fingerprint])
|
|
@@index([projectKey, status])
|
|
@@index([userIdHash, startedAt])
|
|
@@map("sessions_meta")
|
|
}
|
|
|
|
model CompressedSession {
|
|
sessionId String @id
|
|
fingerprint String
|
|
semanticTimelineMinioKey String
|
|
tokenCountInput Int
|
|
tokenCountEstOutput Int
|
|
compressionRatio Float?
|
|
sanitizationMatchCount Int @default(0)
|
|
sanitizationBreakdown Json?
|
|
isBundle Boolean @default(false)
|
|
bundleSessionIds String[]
|
|
readyForAnalysisAt DateTime @default(now())
|
|
createdAt DateTime @default(now())
|
|
|
|
session SessionMeta @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([fingerprint])
|
|
@@map("compressed_sessions")
|
|
}
|
|
|
|
model IngestionWatermark {
|
|
projectKey String @id
|
|
lastPolledAt DateTime
|
|
posthogCursor String?
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@map("ingestion_watermarks")
|
|
}
|
|
|
|
// PostHog identify() person properties cache (24h TTL).
|
|
model PosthogPersonCache {
|
|
distinctId String @id
|
|
properties Json
|
|
groups Json?
|
|
refreshedAt DateTime
|
|
ttlAt DateTime
|
|
|
|
@@index([ttlAt])
|
|
@@map("posthog_person_cache")
|
|
}
|
|
|
|
// PostHog group analytics (e.g. company) cache (24h TTL).
|
|
model PosthogGroupCache {
|
|
groupType String
|
|
groupKey String
|
|
properties Json
|
|
refreshedAt DateTime
|
|
ttlAt DateTime
|
|
|
|
@@id([groupType, groupKey])
|
|
@@index([ttlAt])
|
|
@@map("posthog_group_cache")
|
|
}
|
|
|
|
// Per-session custom events fetched from PostHog (joined by $session_id).
|
|
// Stored so compression can interleave them with rrweb timeline.
|
|
model SessionCustomEvent {
|
|
id BigInt @id @default(autoincrement())
|
|
sessionId String
|
|
eventName String
|
|
timestamp DateTime
|
|
properties Json
|
|
|
|
@@index([sessionId, timestamp])
|
|
@@map("session_custom_events")
|
|
}
|
|
|
|
// ---------- Phase 6b: LLM Analysis ----------
|
|
|
|
model PromptTemplate {
|
|
id String @id @default(cuid())
|
|
tag String
|
|
version Int
|
|
name String
|
|
systemPrompt String @db.Text
|
|
userPromptTemplate String @db.Text
|
|
outputSchemaJson Json
|
|
modelTier String // "flash" | "pro"
|
|
maxOutputTokens Int @default(1500)
|
|
temperature Float @default(0.3)
|
|
active Boolean @default(true)
|
|
performanceStats Json @default("{}")
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@unique([tag, version])
|
|
@@map("prompt_templates")
|
|
}
|
|
|
|
model Insight {
|
|
id String @id @default(cuid())
|
|
projectKey String
|
|
type String
|
|
severity String
|
|
status String @default("new")
|
|
fingerprint String
|
|
title String
|
|
body Json
|
|
relatedSessionIds String[]
|
|
occurrenceCount Int @default(1)
|
|
uniqueUserCount Int @default(1)
|
|
firstSeenAt DateTime
|
|
lastSeenAt DateTime
|
|
confidence Float?
|
|
priorityScore Int @default(0)
|
|
sourcePromptTag String
|
|
sourcePromptVersion Int
|
|
sourceModel String
|
|
sourceCostUsd Float
|
|
githubIssueUrl String?
|
|
githubIssueId BigInt?
|
|
githubIssueNumber Int?
|
|
githubIssueState String?
|
|
shippedAt DateTime?
|
|
validationStartedAt DateTime?
|
|
validationPeriodDays Int @default(14)
|
|
regressionDetected Boolean @default(false)
|
|
validatedAt DateTime?
|
|
founderNotes String? @db.Text
|
|
founderSeverityOverride String?
|
|
founderPriority Int?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@unique([projectKey, fingerprint])
|
|
@@index([status, projectKey])
|
|
@@index([severity, status])
|
|
@@index([lastSeenAt(sort: Desc)])
|
|
@@index([priorityScore(sort: Desc)])
|
|
@@map("insights")
|
|
}
|
|
|
|
model CostLedger {
|
|
id String @id @default(cuid())
|
|
insightId String?
|
|
sessionId String?
|
|
projectKey String
|
|
promptTag String?
|
|
promptVersion Int?
|
|
provider String // "deepseek" | "anthropic" | "openrouter"
|
|
model String
|
|
tier String // "flash" | "pro"
|
|
tokensInputCacheMiss Int
|
|
tokensInputCacheHit Int
|
|
tokensOutput Int
|
|
costInputCacheMissUsd Float
|
|
costInputCacheHitUsd Float
|
|
costOutputUsd Float
|
|
costTotalUsd Float
|
|
cacheHitRatio Float
|
|
callDurationMs Int?
|
|
wasFallback Boolean @default(false)
|
|
wasRetry Boolean @default(false)
|
|
errorCode String?
|
|
createdAt DateTime @default(now())
|
|
|
|
@@index([createdAt])
|
|
@@index([projectKey, createdAt])
|
|
@@index([insightId])
|
|
@@map("cost_ledger")
|
|
}
|
|
|
|
model BudgetSetting {
|
|
id String @id @default(cuid())
|
|
projectKey String? // null = global
|
|
settingKey String
|
|
settingValue Json
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@unique([projectKey, settingKey])
|
|
@@map("budget_settings")
|
|
}
|
|
|
|
// ---------- Phase 6e: Eval framework ----------
|
|
|
|
model EvalSet {
|
|
id String @id @default(cuid())
|
|
promptTag String
|
|
name String
|
|
description String?
|
|
cases Json // [{ id, timeline, expected: {...}, rubric: {severity, hypothesis, ...} }]
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
runs EvalRun[]
|
|
|
|
@@index([promptTag])
|
|
@@map("eval_sets")
|
|
}
|
|
|
|
model EvalRun {
|
|
id String @id @default(cuid())
|
|
evalSetId String
|
|
promptTag String
|
|
promptVersion Int
|
|
model String
|
|
totalCases Int
|
|
passedSchema Int // JSON parse + schema validate succeeded
|
|
passedSeverity Int // severity matched expected
|
|
passedRubric Int // overall rubric pass (subjective fields)
|
|
totalCostUsd Float
|
|
totalDurationMs Int
|
|
avgInputTokens Int
|
|
avgOutputTokens Int
|
|
results Json // per-case: { caseId, ok, errors[], output }
|
|
createdAt DateTime @default(now())
|
|
|
|
evalSet EvalSet @relation(fields: [evalSetId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([evalSetId, createdAt])
|
|
@@index([promptTag, promptVersion])
|
|
@@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:
|
|
// the spoke is never written to). Append-only from the UI — delete is allowed
|
|
// while we're early; can tighten to audit-only soft-delete later.
|
|
model SaseUserNote {
|
|
id String @id @default(cuid())
|
|
saseUserId String @map("sase_user_id")
|
|
authorUserId String @map("author_user_id") // panel User.id
|
|
body String
|
|
pinned Boolean @default(false)
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
|
|
@@index([saseUserId, pinned, createdAt])
|
|
@@map("sase_user_notes")
|
|
}
|