Files
sp/apps/web/prisma/schema.prisma
Semih 400ed224c5 feat(archive): add groups + recordings to the PostHog archive
- groups: posthog-resource-archive now also snapshots group types + group
  instances (groups_types/ + groups/?group_type_index) into
  posthog_resource_snapshots (change-detected).
- recordings: new posthog-recording-archive job (@*/6h) captures complete
  metadata for EVERY recording into posthog_recordings (skipDuplicates over a
  rolling 90d window) + MinIO dump — a superset of the insight pipeline's
  promoted SessionMeta subset. rrweb blobs still archived by archive-recordings.
- /posthog-archive browser: Groups / Group Types tabs + a Recordings tab.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 21:19:54 +03:00

644 lines
21 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())
// Raw rrweb recording preservation (archived to MinIO before PostHog's ~30-day deletion).
rrwebArchivedAt DateTime?
rrwebArchiveKey String?
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
// Additional fingerprints semantically merged into this insight by the dedup
// gate: a new session whose own fingerprint differs but which the LLM judged
// to be the SAME underlying problem gets its fingerprint aliased here, so the
// next identical session fast-paths (no re-analysis, no duplicate row).
aliasFingerprints String[] @default([])
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")
}
// Daily evening brief — one 360° report per project per day. Generated by the
// worker (sase-evening-brief job) from Sase DB + PostHog + Meta + pipeline, then
// rendered at /projects/sase/briefs and pushed to Telegram at 20:00.
model Brief {
id String @id @default(cuid())
projectKey String @default("sase")
briefDate DateTime @db.Date
status String @default("generated") // generated | failed
headline String @db.Text
summary String @db.Text
healthScore Int @default(0)
body Json // structured: { critical[], sections[], recommendations[] }
metrics Json // raw 360 aggregates that fed the LLM (audit + page render)
telegramSent Boolean @default(false)
sourceModel String @default("")
sourceCostUsd Float @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([projectKey, briefDate])
@@index([projectKey, briefDate(sort: Desc)])
@@map("briefs")
}
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")
}
// ---------- Phase 9: External Observability Archive ----------
// PostHog raw event stream mirrored to our DB permanently (free tier deletes
// events on a rolling window). Lossless: full properties kept as JSONB, with
// hot fields promoted to indexed columns. Also cold-dumped to MinIO as gzip JSONL.
model PosthogEvent {
uuid String @id // PostHog event uuid → natural dedup key
projectKey String // "sase"
event String
distinctId String
personId String?
sessionId String? // $session_id — joins to SessionMeta.id
timestamp DateTime // PostHog event time
properties Json // full properties — lossless
ingestedAt DateTime @default(now())
dumpedAt DateTime? // set once written to MinIO cold partition
@@index([projectKey, timestamp])
@@index([event, timestamp])
@@index([distinctId, timestamp])
@@index([dumpedAt])
@@map("posthog_events")
}
// Append-only history of PostHog person properties. Free tier overwrites the
// live person; a new row is written only when properties change (propertiesHash),
// so this is a compact timeline of how each person evolved.
model PosthogPersonSnapshot {
id String @id @default(cuid())
projectKey String
personId String // PostHog person uuid (persons.id) — joins PosthogEvent.personId
distinctIds String[] // all distinct_ids merged into this person
propertiesHash String // change-detection key
properties Json
capturedAt DateTime @default(now())
@@index([personId, capturedAt])
@@index([projectKey, capturedAt])
@@map("posthog_person_snapshots")
}
// Append-only history of cohort definitions + membership counts. New row only
// when count or filters change (stateHash).
model PosthogCohortSnapshot {
id String @id @default(cuid())
projectKey String
cohortId Int
name String
count Int?
isStatic Boolean @default(false)
stateHash String // hash of count + filters → change-detection
filters Json
capturedAt DateTime @default(now())
@@index([cohortId, capturedAt])
@@index([projectKey, capturedAt])
@@map("posthog_cohort_snapshots")
}
// Generic change-history of every PostHog "resource" module beyond events:
// surveys, feature_flags, experiments, dashboards, insights, annotations,
// actions. One row is appended only when a resource's config changed
// (stateHash compare); the full payload is kept lossless in `data`, and a full
// per-type daily snapshot is also dumped to MinIO. Survey responses + flag
// calls are events (already in posthog_events) — this captures the definitions.
model PosthogResourceSnapshot {
id String @id @default(cuid())
projectKey String
resourceType String // survey | feature_flag | experiment | dashboard | insight | annotation | action
resourceId String // PostHog resource id (stringified)
name String?
stateHash String // change-detection over the non-volatile config
data Json // full resource payload, lossless
capturedAt DateTime @default(now())
dumpedAt DateTime?
@@index([projectKey, resourceType, capturedAt])
@@index([resourceType, resourceId, capturedAt])
@@index([dumpedAt])
@@map("posthog_resource_snapshots")
}
// Complete metadata for EVERY session recording (not just the insight-pipeline's
// promoted subset in SessionMeta). One row per recording; full list-item payload
// kept lossless. rrweb blobs themselves are archived separately to MinIO by the
// archive-recordings job for ingested sessions.
model PosthogRecording {
id String @id // PostHog recording / session id
projectKey String
distinctId String?
personName String?
startTime DateTime?
endTime DateTime?
durationSec Int?
activeSeconds Int?
clickCount Int?
keypressCount Int?
mouseActivityCount Int?
consoleErrorCount Int?
startUrl String?
ongoing Boolean @default(false)
properties Json // full recording list item, lossless
capturedAt DateTime @default(now())
dumpedAt DateTime?
@@index([projectKey, startTime])
@@index([distinctId])
@@index([dumpedAt])
@@map("posthog_recordings")
}
// ---------- Phase C: Sentry archive ----------
// Sentry issue (group) aggregate state. Upserted to the latest snapshot — the
// raw history lives in SentryEvent; the issue row is the current grouping/metadata.
model SentryIssue {
id String @id // Sentry issue/group id
projectKey String // "sase" — panel-side project key
sentrySourceProject String? // Sentry project slug (e.g. "python", "sase-web") — distinguishes browser vs server errors when one panel project has multiple Sentry projects
shortId String?
title String?
culprit String?
level String?
status String?
type String?
count Int? // total events as of lastSyncedAt
userCount Int?
firstSeen DateTime?
lastSeen DateTime?
permalink String?
metadata Json?
ingestedAt DateTime @default(now())
lastSyncedAt DateTime @default(now())
@@index([projectKey, lastSeen])
@@index([projectKey, sentrySourceProject, lastSeen])
@@map("sentry_issues")
}
// Individual Sentry events (the at-risk raw occurrences; free tier prunes ~30d).
// Lossless full payload as JSONB; also cold-dumped to MinIO.
model SentryEvent {
eventId String @id // Sentry event id (32 hex)
issueId String? // parent issue/group id
projectKey String // panel-side project key (e.g. "sase")
sentrySourceProject String? // Sentry project slug the event came from ("python", "sase-web")
title String?
message String?
level String?
platform String?
timestamp DateTime // event occurrence time
tags Json?
payload Json // full event payload — lossless
ingestedAt DateTime @default(now())
dumpedAt DateTime?
@@index([projectKey, timestamp])
@@index([projectKey, sentrySourceProject, timestamp])
@@index([issueId, timestamp])
@@index([dumpedAt])
@@map("sentry_events")
}