14 Commits

Author SHA1 Message Date
Semih
9a479f9a5b fix(insights): distinguish client-side VIN validation rejects from provider failures
Reproducing insight cmpvfrjgc000114fzc7cdyh66 (a P1 "PL24 timeout" false
positive): trial user typed VW part numbers ("500 907 521", "5Q0 907 521")
into the VIN field on /; client-side regex rejected them with "Geçersiz şase
numarası. 17 karakter olmalı". No provider was called. The pipeline still
tagged the session as `vin_decode_fail_pattern`, routed to `provider_quality`,
and the LLM dutifully invented a PL24 outage.

Root cause spans three files:

1. tagger.ts grouped vin_decode_failed by `provider_attempted ?? source`. When
   `provider_attempted` is missing, `source: "landing"` (a UI location) was
   treated as a provider name, so a 1-provider set was synthesized and
   `vin_decode_fail_pattern` (P1) was emitted.

2. compress.ts formatCustom whitelist excluded `error`, `source`, `vin`. The
   LLM therefore never saw "Geçersiz şase numarası" or the offending input.
   Pattern 3 mechanical hypothesis told it "check provider health" regardless.

3. prompts.ts pickPromptTag routed any `vin_decode_fail_pattern` straight to
   `provider_quality` with no input-quality check, and the v3 system prompt
   had no guardrail for client-side validation rejects.

Fix:
- tagger: detect client-side rejects by `error` regex (Turkish + English) and
  by VIN shape (length != 17 or contains I/O/Q). When all fails are client
  rejects, emit new tag `vin_decode_client_validation_fail` at P3 instead of
  `vin_decode_fail_pattern` at P1. Real provider failures now require
  `provider_attempted` to be set (no more `source` fallback).
- compress: add `error`, `source`, `vin` to the formatCustom property
  whitelist so the LLM can see the actual failure context. Split Pattern 3
  into client-reject vs. real-provider-failure branches with distinct
  Turkish hypotheses.
- prompts: route `vin_decode_client_validation_fail` to `ux_friction` before
  the provider rule. Ship provider_quality v4 with an explicit guardrail
  instructing the model to return confidence ≤0.15 and reclassify when the
  inlined event properties show client-side rejection.

The seed-runtime upsert path deactivates the active v3 template on next
worker boot and inserts v4 in its place — no manual SQL needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 23:12:39 +03:00
59cb1f63ec Merge pull request 'feat(insights): multi-project Sentry archive' (#3) from feat/sentry-multi-project into main 2026-06-01 15:38:16 +00:00
Semih
8a81cb77bf feat(insights): multi-project Sentry archive
The sentry-archive job was hardcoded to a single SENTRY_PROJECT slug.
Now that sase has a second Sentry project (sase-web, browser SDK), the
worker has to pull both — otherwise frontend events live only in
Sentry's UI and never reach the panel's sentry_events table for the
behavioral-insight pipeline to pick up.

Changes:

- SENTRY_PROJECT becomes a CSV (e.g. "python,sase-web") parsed once at
  module load into PROJECTS[]. Backward-compatible: a single value
  keeps working exactly as before.
- Issues are fetched org-wide in a single loop (unchanged endpoint)
  but each issue's Sentry project slug is now persisted via the new
  `sentrySourceProject` column on sentry_issues.
- Events are project-scoped on Sentry's side, so the job iterates
  projects and calls listEventsPage(slug, cursor) for each. Per-project
  pagination + dedup is preserved. A project-level failure is recorded
  in `error` but doesn't abort the other projects.
- New result shape: `perProject: { [slug]: { fetched, inserted, duplicate } }`
  Pipeline log gains a `[python=N/M sase-web=N/M]` breakdown so it's
  obvious at a glance which project produced what.
- Schema: nullable `sentrySourceProject` on both SentryIssue and
  SentryEvent (+ composite index per projectKey for queries that want
  to filter "panel project = sase AND sentry project = sase-web").
  Migration is a pure nullable add — `prisma db push` on container
  start is safe. Existing rows stay null until next sync touches them.

After merge, set `SENTRY_PROJECT=python,sase-web` on the panel-worker
Coolify app and redeploy.
2026-06-01 18:37:10 +03:00
7eca679ab8 Merge pull request 'feat(insights): permanent archive of PostHog (events+recordings+identity) + Sentry (Phase A+B+C)' (#2) from feat/observability-archive into main 2026-05-27 19:13:31 +00:00
Semih
928728dc66 feat(insights): Phase C — Sentry issues + events archive
Mirrors Sentry into our DB before the free tier prunes events (~30d).

- `SentryIssue` (aggregate state, upserted to latest) + `SentryEvent`
  (raw occurrences, lossless full payload JSONB, dedup by eventId, cold
  dump to MinIO `sentry-archive/{project}/YYYY/MM/DD.jsonl.gz`).
- `lib/sentry.ts`: read-only client, Link-header cursor pagination,
  listIssuesPage / listEventsPage. EU-region aware (SENTRY_API_BASE).
- `sentry-archive` job @hourly: issues upsert + events newest-first with
  skipDuplicates, stops once a page is all-duplicates (caught up).
- Config via env: SENTRY_AUTH_TOKEN / SENTRY_ORG / SENTRY_PROJECT /
  SENTRY_API_BASE.

Verified live against otolog/python (EU): a test event archived on run 1,
0 inserts / 1 duplicate on run 2 (dedup), issue upserted idempotently.

Note: Sase API currently sends to an inaccessible org's DSN (hardcoded
fallback); repointing SENTRY_DSN to otolog/python is a separate deploy step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:32:50 +03:00
Semih
ab86dbdf59 feat(insights): Phase B — PostHog person + cohort snapshots
Append-only history of person properties and cohort definitions, since
the free tier overwrites the live person / prunes data. A new row is
written only when the payload changes (hash compare), so the tables stay
a compact change-timeline rather than a daily full copy.

- `PosthogPersonSnapshot` (personId join key to PosthogEvent.personId,
  all distinct_ids, properties JSONB, propertiesHash).
- `PosthogCohortSnapshot` (cohortId, name, count, filters, stateHash).
- `listPersons` / `listCohorts` REST helpers (next-pagination) in posthog.ts.
- `stableHash` (sorted-key JSON hash) in hash.ts for change detection.
- `posthog-identity-archive` job wired @03:00 daily. Latest-hash lookup
  via one DISTINCT ON query, inserts only changed rows via createMany.

Verified against prod: 242 persons → 242 snapshots on first run, 0 on
immediate re-run (change detection). 0 cohorts currently → graceful no-op.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 20:47:20 +03:00
Semih
3941b7018f fix(insights): chunk rrweb blob fetch + rate-limit handling
Live verification surfaced two issues in archive-recordings:
- blob_v2 rejects wide blob-key ranges (a 23-key span 400s; ~5 is fine).
  Fetch in contiguous chunks of BLOB_CHUNK (default 10) and concatenate.
- PostHog's snapshot API is aggressively rate-limited; back-to-back
  sessions tripped 429. Pace requests (per-session + per-chunk delays)
  and stop the run on sustained 429 — unarchived rows keep
  rrwebArchivedAt=null and retry next cycle (oldest-first ordering
  protects soon-to-be-deleted recordings first). Result now carries
  rateLimited.

Verified against prod: 87- and 23-blob recordings reconstruct fully
(no 400), failed=0, graceful 429 stop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 20:40:43 +03:00
Semih
f663c0aa09 feat(insights): permanent archive of PostHog events + raw recordings
Phase A of the external observability archive. PostHog Cloud free tier
deletes data on a rolling window (events ~1yr, session recordings ~30d);
this mirrors both into our own infra permanently (hot Postgres + cold
MinIO gzip JSONL).

PostHog raw event archive:
- New `PosthogEvent` model (uuid PK → dedup, full properties JSONB,
  hot columns promoted + indexed).
- `posthog-event-archive` job: watermark-paged HogQL pull (cursor in the
  existing IngestionWatermark via a "sase:events" stream key), createMany
  + skipDuplicates for idempotency, 365d backfill. Daily closed-day cold
  dump to MinIO `posthog-archive/{project}/YYYY/MM/DD.jsonl.gz`.
- `hogqlQuery` helper added to posthog.ts. Timestamp cursor uses
  parseDateTimeBestEffort() — ClickHouse 500s on a raw ISO8601 literal
  (verified live against the project).

Raw rrweb recording preservation (separate from compress, which only
covers scored sessions and caps blobs):
- `SessionMeta.rrwebArchivedAt` / `rrwebArchiveKey`.
- `archive-recordings` job: every recording within 25d (margin before
  30d deletion), full blob range (no 12-cap), gzip → MinIO
  `rrweb-archive/{project}/YYYY/MM/DD/{sessionId}.jsonl.gz`.

- `putBuffer` gzip helper in minio.ts.
- Both jobs wired into pipeline.ts (event-archive@*/15min, recordings@*/6h).
- retention.ts untouched → new table + buckets persist forever (the goal).

Schema applies via the existing `prisma db push` on web start (additive:
new table + nullable columns).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 20:15:29 +03:00
Semih
fb33692452 feat(vin-decode): VIN row detail modal with timings + behaviour
Click a VIN in the list to open a modal with the full decode internals:
- Proxy times per provider (pcat/emex/pl24/vin-api) from query_logs.timings
- Race/lock status (lock_wait ms + cache_source='lock_wait' → "aynı VIN
  paralelde çözülüyordu"), candidate_pick, pl24 circuit, aborted
- Candidate counts (pcat_car_count, emex_candidate_count), result_kind, wmi
- User behaviour for that vehicle, correlated from PostHog custom events in
  the panel DB by user_id + vehicle_id: categories opened (parts_panel_viewed)
  and OEM copied (oem_code_copied + distinct codes)

New: getVinDecodeDetail/getVinUserBehavior (cross-DB: sase RO + panel),
GET /api/sase/query-log/[id] (auth-guarded), client VinDetailButton modal.
Both queries verified against prod data.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 02:31:28 +03:00
Semih
09438cf513 fix(vin-decode): repair Business page 500 + drop redundant VIN buttons
- Business page returned 500: getTrialFunnel's `ORDER BY CASE bucket ...`
  referenced the SELECT-list alias inside an expression, which Postgres
  can't resolve (error 42703 "column bucket does not exist"). Wrap the
  GROUP BY in a subquery so `bucket` is a real column the ORDER BY can use.
  Verified against prod: original errors, fixed query returns ordered buckets.
- Remove the "Çözülen / Çözülemeyen VIN'ler" dashboard buttons — the VIN
  list already has a success/error status filter, so they were redundant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 02:22:21 +03:00
Semih
985592aa73 feat(vin-decode): WMI opportunity radar, per-source p50, unknown-VIN spike alarm
Systematize Faz 4 + close three observability gaps:

- WMI opportunity radar (getWmiOpportunities): group query_logs by
  substring(vin,1,3) = WMI, rank low-success WMIs by distinct-user demand.
  brandMatched=0 flags fully-uncovered manufacturer codes. Surfaced on the
  Trends page (#wmi) + a dashboard header shortcut. Replaces the manual Faz 4.
- Per-source p50: add percentile_cont(0.50) to getProviderAttempts and
  getProviderDeepStats; show P50·P95 on the dashboard and provider drill-down.
- unknown_vin_spike anomaly: track "tanınamadı" rate in the 15min/baseline
  windows, fire when it jumps >=2x baseline (or surges from ~0), and attribute
  the dominant failing source (+aborted count) in the Telegram message. Runs
  in the existing 5min anomaly cron; worker/telegram unchanged (generic type).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 01:58:26 +03:00
Semih
f05e0bc808 feat(vin-decode): add solved/unsolved VIN list shortcut buttons
Add two header buttons on the VIN decode dashboard linking to the
existing /vins list pre-filtered by success=true / success=false,
carrying the current time-range window via the `from` param so the
list matches the dashboard view. Shows actual VIN codes (not counts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 00:07:55 +03:00
Semih
0419cc271c feat(content): Phase 8c publish layer — panel→n8n webhook (sync) + LinkedIn/blog
Approved drafts publish via a single n8n `publish-content` webhook; the panel
POSTs the effective content (founder edits merged over generated body) and
awaits a synchronous Respond-to-Webhook result (sp.semih.ai is Tailscale-only,
so we avoid an n8n→panel callback). n8n routes by channel.

- lib/n8n.ts: publishToN8n client (X-Content-Secret header, timeout, tolerant
  result parsing: ok|success + publishedUrl|url|postUrl|permalink)
- publishDraft server action: approved|failed → publishing → published(+url) /
  failed(+error), audit-logged; effective content = bodyJson + founderEdits
- DraftCard: "Yayınla" / "Yeniden yayınla" button + publishing state
- docs/n8n: importable publish-content workflow (Webhook → Switch → LinkedIn /
  HTTP-blog → Respond) + runbook (contract, panel envs, LinkedIn OAuth setup,
  blog endpoint = sase.tr POST /blog/posts/internal Bearer)

Needs panel-web envs N8N_PUBLISH_WEBHOOK_URL + N8N_WEBHOOK_SECRET. Publish is a
graceful no-op (clear error) until those are set and the n8n workflow exists.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 01:42:11 +03:00
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
48 changed files with 4187 additions and 52 deletions

View File

@@ -142,6 +142,10 @@ model SessionMeta {
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])
@@ -364,6 +368,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:
@@ -380,3 +449,114 @@ model SaseUserNote {
@@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")
}
// ---------- 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")
}

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,36 @@
import { NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { getVinDecodeDetail, getVinUserBehavior } from "@/lib/sase/vin-detail";
export const dynamic = "force-dynamic";
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export async function GET(_req: Request, ctx: { params: Promise<{ id: string }> }) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) {
return NextResponse.json({ ok: false, error: "unauthenticated" }, { status: 401 });
}
const { id } = await ctx.params;
if (!UUID_RE.test(id)) {
return NextResponse.json({ ok: false, error: "bad_id" }, { status: 400 });
}
const detail = await getVinDecodeDetail(id);
if (!detail) {
return NextResponse.json({ ok: false, error: "not_found" }, { status: 404 });
}
// Behaviour is best-effort: if the panel DB query fails, still return the
// decode detail rather than 500 the whole modal.
let behavior = null;
try {
behavior = await getVinUserBehavior(detail.userId, detail.vehicleId);
} catch {
behavior = null;
}
return NextResponse.json({ ok: true, detail, behavior });
}

View File

@@ -0,0 +1,214 @@
"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";
import { publishToN8n } from "@/lib/n8n";
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");
}
// Publish an approved (or previously-failed) draft via the n8n publish webhook.
// Synchronous: we await n8n's response and persist the final status here.
export async function publishDraft(draftId: string) {
await requireSession();
const draft = await prisma.contentDraft.findUnique({
where: { id: draftId },
include: { topic: { select: { title: true } } },
});
if (!draft) throw new Error("draft not found");
if (!["approved", "failed"].includes(draft.status)) {
throw new Error("only approved (or failed) drafts can be published");
}
// Effective content = generated body with founder edits layered on top.
const base = (draft.bodyJson && typeof draft.bodyJson === "object" && !Array.isArray(draft.bodyJson)
? (draft.bodyJson as Record<string, unknown>)
: {});
const edits = (draft.founderEdits && typeof draft.founderEdits === "object" && !Array.isArray(draft.founderEdits)
? (draft.founderEdits as Record<string, unknown>)
: {});
const content = { ...base, ...edits };
await prisma.contentDraft.update({
where: { id: draftId },
data: { status: "publishing", publishError: null },
});
revalidatePath(`/content/t/${draft.topicId}`);
const result = await publishToN8n({
draftId,
channel: draft.channel,
projectKey: PROJECT_KEY,
topicTitle: draft.topic.title,
content,
});
await prisma.contentDraft.update({
where: { id: draftId },
data: result.ok
? {
status: "published",
publishedUrl: result.publishedUrl ?? null,
publishedAt: new Date(),
publishError: null,
}
: { status: "failed", publishError: (result.error ?? "unknown").slice(0, 500) },
});
await writeAudit({
projectKey: PROJECT_KEY,
endpoint: `/content/drafts/${draftId}/publish`,
method: "POST",
requestPayload: { channel: draft.channel, ok: result.ok },
responseStatus: result.ok ? 200 : 502,
});
revalidatePath(`/content/t/${draft.topicId}`);
revalidatePath("/content");
if (!result.ok) throw new Error(result.error ?? "publish failed");
return result.publishedUrl ?? null;
}

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,209 @@
"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, publishDraft } 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>
{(props.status === "approved" || props.status === "failed") && (
<Button
size="sm"
disabled={pending}
onClick={() =>
fire(
() => publishDraft(props.draftId),
props.status === "failed" ? "yeniden yayınlanıyor" : "yayınlanıyor",
)
}
>
{props.status === "failed" ? "Yeniden yayınla" : "Yayınla"}
</Button>
)}
{flash && <span className="text-xs text-muted-foreground">{flash}</span>}
</div>
)}
{props.status === "publishing" && (
<div className="border-t pt-2 text-xs text-muted-foreground">n8n'e gönderildi, yayınlanıyor</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

@@ -121,6 +121,13 @@ export default async function VinDecodePage({
>
Trends
</Link>
<Link
href="/projects/sase/vin-decode/trends#wmi"
className={buttonVariants({ variant: "outline", size: "sm" })}
title="Sıradaki eklenecek WMI adayları (talebe göre)"
>
WMI fırsatları
</Link>
</div>
</div>
@@ -208,7 +215,7 @@ export default async function VinDecodePage({
label: a.provider,
href: `/projects/sase/vin-decode/providers/${encodeURIComponent(a.provider === "pcat" ? "parts-catalogs" : a.provider === "vin_api" ? "vin-api" : a.provider)}`,
count: a.attemptCount,
meta: `Ø ${ms(a.avgMs)} · P95 ${ms(a.p95Ms)}`,
meta: `P50 ${ms(a.p50Ms)} · P95 ${ms(a.p95Ms)}`,
}))}
/>
</CardContent>

View File

@@ -109,16 +109,16 @@ export default async function ProviderDeepPage({
value={`${(stats.wins.successRate * 100).toFixed(1)}%`}
/>
<Kpi
label="Avg RT"
value={stats.wins.avgMs != null ? `${stats.wins.avgMs}ms` : "—"}
hint={`P95 ${stats.wins.p95 != null ? `${stats.wins.p95}ms` : "—"}`}
label="P50 / P95 RT"
value={stats.wins.p50 != null ? `${stats.wins.p50}ms` : "—"}
hint={`P95 ${stats.wins.p95 != null ? `${stats.wins.p95}ms` : "—"} · Ø ${stats.wins.avgMs != null ? `${stats.wins.avgMs}ms` : "—"}`}
/>
<Kpi
label="Chain'de görüldü"
value={stats.attempts.total.toLocaleString("tr-TR")}
hint={
stats.attempts.total > 0
? `Ø ${stats.attempts.avgMs}ms · P95 ${stats.attempts.p95 ?? ""}ms`
? `P50 ${stats.attempts.p50 ?? ""}ms · P95 ${stats.attempts.p95 ?? ""}ms`
: "chain key'ı yok"
}
/>

View File

@@ -20,6 +20,7 @@ import {
getWeeklyGrowth,
getProviderShareTrend,
getUnderSupportedBrands,
getWmiOpportunities,
getPeakHeatmap,
getCacheHitTrend,
getEfficiencyTrend,
@@ -42,12 +43,13 @@ const PROVIDER_COLORS: Record<string, string> = {
};
export default async function TrendsPage() {
const [volume, growth, shareTrend, underSupported, heatmap, cache, efficiency] =
const [volume, growth, shareTrend, underSupported, wmiOpps, heatmap, cache, efficiency] =
await Promise.all([
getLongTermVolume(90),
getWeeklyGrowth(12),
getProviderShareTrend(30),
getUnderSupportedBrands(30, 20, 0.75),
getWmiOpportunities(30, 15, 0.7),
getPeakHeatmap(30),
getCacheHitTrend(30),
getEfficiencyTrend(30),
@@ -252,6 +254,82 @@ export default async function TrendsPage() {
</CardContent>
</Card>
{/* 4b. WMI opportunity radar — data-driven Faz 4 */}
<Card id="wmi">
<CardHeader>
<CardDescription>
4b. WMI fırsat radarı (son 30g, hacim 15, başarı 70%) sıradaki
ekleme adayları, talebe göre sıralı
</CardDescription>
</CardHeader>
<CardContent>
{wmiOpps.length === 0 ? (
<p className="text-sm text-muted-foreground">
Düşük başarılı WMI yok talep edilen üretici kodları yeterince
karşılanıyor.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>WMI</TableHead>
<TableHead>Durum / marka</TableHead>
<TableHead className="text-right">Talep (kullanıcı)</TableHead>
<TableHead className="text-right">Toplam</TableHead>
<TableHead className="text-right">Fail</TableHead>
<TableHead className="text-right">Başarı</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{wmiOpps.map((w) => (
<TableRow key={w.wmi}>
<TableCell className="font-mono text-xs">{w.wmi}</TableCell>
<TableCell className="text-xs">
{w.brandMatched === 0 ? (
<Badge variant="destructive">eşleşmesiz WMI</Badge>
) : (
<>
<span className="font-mono">{w.topBrandSlug ?? "?"}</span>
{w.topBrandName && (
<span className="ml-1 text-muted-foreground">
{w.topBrandName}
</span>
)}
<span className="ml-1 text-muted-foreground">· decode zayıf</span>
</>
)}
{w.topResultKind && (
<span className="ml-1 text-muted-foreground">
({w.topResultKind})
</span>
)}
</TableCell>
<TableCell className="text-right font-medium tabular-nums">
{w.uniqueUsers}
</TableCell>
<TableCell className="text-right tabular-nums">
{w.total.toLocaleString("tr-TR")}
</TableCell>
<TableCell className="text-right tabular-nums text-destructive">
{w.failed.toLocaleString("tr-TR")}
</TableCell>
<TableCell className="text-right tabular-nums text-destructive">
{(w.successRate * 100).toFixed(1)}%
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<p className="mt-2 text-xs text-muted-foreground">
&quot;eşleşmesiz WMI&quot; = hiç markaya bağlanamayan üretici kodu
eklenince tamamen yeni kapsama. Talep (farklı kullanıcı) sütununa
göre sıralı: en çok kişinin isteyip alamadığı WMI en üstte. Faz 4&apos;ün
el yordamı yerine veri-güdümlü hali.
</p>
</CardContent>
</Card>
{/* 5. Peak heatmap */}
<Card>
<CardHeader>

View File

@@ -0,0 +1,307 @@
"use client";
import { useEffect, useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
type Timings = {
pcat?: number;
emex?: number;
pl24?: number;
vin_api?: number;
lock_wait?: number;
cache_source?: string;
cache_neg_hit?: boolean;
pcat_car_count?: number;
emex_candidate_count?: number;
candidate_pick?: string;
pl24_circuit_open?: boolean;
aborted?: boolean;
result_kind?: string;
wmi?: string;
[k: string]: unknown;
};
type Detail = {
id: string;
vin: string;
userId: string;
userEmail: string | null;
userName: string | null;
brandSlug: string | null;
brandName: string | null;
source: string | null;
success: boolean;
responseTimeMs: number | null;
errorMessage: string | null;
createdAt: string;
vehicleId: string | null;
timings: Timings | null;
};
type Behavior = {
oemCopiedCount: number;
oemCodes: string[];
categoriesOpened: number;
partsPanelViews: number;
firstAt: string | null;
lastAt: string | null;
} | null;
type ApiResponse =
| { ok: true; detail: Detail; behavior: Behavior }
| { ok: false; error: string };
function ms(v: number | null | undefined): string {
if (v == null) return "—";
if (v >= 1000) return `${(v / 1000).toFixed(2)}s`;
return `${v}ms`;
}
const PROVIDER_KEYS: Array<[keyof Timings, string]> = [
["pcat", "parts-catalogs"],
["emex", "emex"],
["pl24", "pl24"],
["vin_api", "vin-api"],
];
export function VinDetailButton({ id, vin }: { id: string; vin: string }) {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [detail, setDetail] = useState<Detail | null>(null);
const [behavior, setBehavior] = useState<Behavior>(null);
useEffect(() => {
if (!open || detail || loading) return;
setLoading(true);
setError(null);
fetch(`/api/sase/query-log/${id}`, { cache: "no-store" })
.then(async (r) => (await r.json()) as ApiResponse)
.then((data) => {
if (data.ok) {
setDetail(data.detail);
setBehavior(data.behavior);
} else {
setError(data.error);
}
})
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
.finally(() => setLoading(false));
}, [open, id, detail, loading]);
const t = detail?.timings ?? null;
const proxies = t ? PROVIDER_KEYS.filter(([k]) => typeof t[k] === "number") : [];
const raced = t ? (typeof t.lock_wait === "number" || t.cache_source === "lock_wait") : false;
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="cursor-pointer font-mono text-xs hover:underline"
title={`${vin} — detay`}
>
{vin}
</button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="font-mono text-base">{vin}</DialogTitle>
<DialogDescription>
{detail
? new Date(detail.createdAt).toISOString().slice(0, 19).replace("T", " ")
: "Decode detayı"}
</DialogDescription>
</DialogHeader>
{loading && <p className="text-sm text-muted-foreground">Yükleniyor</p>}
{error && (
<p className="text-sm text-destructive">Hata: {error}</p>
)}
{detail && (
<div className="space-y-4 text-sm">
{/* Decode özet */}
<Section title="Decode">
<Row label="Durum">
{detail.success ? (
<Badge variant="default">ok</Badge>
) : (
<Badge variant="destructive">fail</Badge>
)}
</Row>
<Row label="Kazanan provider">
{detail.source ? (
<Badge variant="outline" className="font-mono">{detail.source}</Badge>
) : (
"—"
)}
</Row>
<Row label="Toplam yanıt">{ms(detail.responseTimeMs)}</Row>
<Row label="Marka">
{detail.brandSlug ? (
<span className="font-mono text-xs">
{detail.brandSlug}
{detail.brandName && (
<span className="ml-1 text-muted-foreground">{detail.brandName}</span>
)}
</span>
) : (
"—"
)}
</Row>
<Row label="Kullanıcı">
<span className="font-mono text-xs">{detail.userEmail ?? detail.userId.slice(0, 8)}</span>
</Row>
{detail.errorMessage && (
<Row label="Hata mesajı">
<span className="text-xs text-destructive">{detail.errorMessage}</span>
</Row>
)}
</Section>
{/* Proxy süreleri */}
<Section title="Proxy süreleri">
{proxies.length === 0 ? (
<p className="text-xs text-muted-foreground">
Provider denemesi yok (cache&apos;ten döndü).
</p>
) : (
proxies.map(([k, label]) => (
<Row key={String(k)} label={label}>
<span className="tabular-nums">{ms(t?.[k] as number)}</span>
</Row>
))
)}
</Section>
{/* Race / kilit */}
<Section title="Race / kilit durumu">
{raced ? (
<Row label="Eşzamanlı istek">
<span className="text-yellow-600">
Aynı VIN paralelde çözülüyordu {ms(t?.lock_wait)} kilit beklendi
</span>
</Row>
) : (
<Row label="Eşzamanlı istek">
<span className="text-muted-foreground">Race yok (kilit beklenmedi)</span>
</Row>
)}
<Row label="cache_source">
<span className="font-mono text-xs">{t?.cache_source ?? "—"}</span>
</Row>
<Row label="candidate_pick">
<span className="font-mono text-xs">{t?.candidate_pick ?? "—"}</span>
</Row>
{t?.pl24_circuit_open && (
<Row label="PL24 circuit">
<Badge variant="destructive">ık (circuit open)</Badge>
</Row>
)}
{t?.aborted && (
<Row label="Abort">
<Badge variant="destructive">bütçe/timeout ile iptal</Badge>
</Row>
)}
</Section>
{/* Aday / sonuç */}
<Section title="Aday & sonuç">
<Row label="result_kind">
<span className="font-mono text-xs">{t?.result_kind ?? "—"}</span>
</Row>
<Row label="PCAT araç adayı">
<span className="tabular-nums">
{typeof t?.pcat_car_count === "number" ? t.pcat_car_count : "—"}
</span>
</Row>
<Row label="EMEX adayı">
<span className="tabular-nums">
{typeof t?.emex_candidate_count === "number" ? t.emex_candidate_count : "—"}
</span>
</Row>
<Row label="WMI">
<span className="font-mono text-xs">{t?.wmi ?? "—"}</span>
</Row>
{t?.cache_neg_hit && (
<Row label="Negatif cache">
<Badge variant="outline">negatif cache hit</Badge>
</Row>
)}
</Section>
{/* Kullanıcı davranışı (bu araç) */}
<Section title="Kullanıcı davranışı (bu araç)">
{!detail.vehicleId ? (
<p className="text-xs text-muted-foreground">
Decode bir araç kaydı üretmedi kategori/OEM davranışı yok.
</p>
) : behavior == null ? (
<p className="text-xs text-muted-foreground">Davranış verisi alınamadı.</p>
) : (
<>
<Row label="Kategori açıldı">
<span className="tabular-nums">{behavior.categoriesOpened}</span>
{behavior.partsPanelViews > 0 && (
<span className="ml-1 text-xs text-muted-foreground">
({behavior.partsPanelViews} görüntüleme)
</span>
)}
</Row>
<Row label="OEM kopyalandı">
{behavior.oemCopiedCount > 0 ? (
<span className="text-emerald-600">
Evet · {behavior.oemCopiedCount} kez ({behavior.oemCodes.length} farklı kod)
</span>
) : (
<span className="text-muted-foreground">Hayır</span>
)}
</Row>
{behavior.oemCodes.length > 0 && (
<div className="flex flex-wrap gap-1 pt-1">
{behavior.oemCodes.map((c) => (
<Badge key={c} variant="secondary" className="font-mono text-xs">
{c}
</Badge>
))}
</div>
)}
</>
)}
</Section>
</div>
)}
</DialogContent>
</Dialog>
</>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="rounded-md border p-3">
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{title}
</p>
<div className="space-y-1">{children}</div>
</div>
);
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-baseline justify-between gap-3">
<span className="text-xs text-muted-foreground">{label}</span>
<span className="text-right">{children}</span>
</div>
);
}

View File

@@ -13,6 +13,7 @@ import { listVinDecodes } from "@/lib/sase/vin-list";
import { saseAdminWired } from "@/lib/admin-sdk/sase";
import { VinsFilterBar } from "./_filter-bar";
import { VinActions } from "./_actions";
import { VinDetailButton } from "./_detail-modal";
import { Pager } from "./_pager";
import { buildHref, type VinsSearchParams } from "./_query";
@@ -115,8 +116,8 @@ export default async function VinsListPage({
<TableCell className="font-mono text-xs">
{r.createdAt.toISOString().slice(5, 16).replace("T", " ")}
</TableCell>
<TableCell className="font-mono text-xs" title={r.vin}>
{r.vin}
<TableCell className="font-mono text-xs">
<VinDetailButton id={r.id} vin={r.vin} />
</TableCell>
<TableCell className="text-xs">
{r.brandSlug ? (

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

68
apps/web/src/lib/n8n.ts Normal file
View File

@@ -0,0 +1,68 @@
// n8n publish client (Phase 8c). The panel POSTs an approved draft to the
// n8n `publish-content` webhook and waits for a synchronous response (n8n's
// "Respond to Webhook" node returns the publish result). Synchronous by
// design: sp.semih.ai is Tailscale-only, so an n8n→panel callback would need
// cross-network reachability we'd rather avoid. n8n routes by `channel`.
const WEBHOOK_URL = process.env.N8N_PUBLISH_WEBHOOK_URL ?? "";
const SECRET = process.env.N8N_WEBHOOK_SECRET ?? "";
const TIMEOUT_MS = Number(process.env.N8N_PUBLISH_TIMEOUT_MS ?? "30000");
export type PublishRequest = {
draftId: string;
channel: string;
projectKey: string;
topicTitle: string;
content: Record<string, unknown>;
};
export type PublishResult = {
ok: boolean;
publishedUrl?: string;
error?: string;
};
export function n8nConfigured(): boolean {
return Boolean(WEBHOOK_URL);
}
export async function publishToN8n(req: PublishRequest): Promise<PublishResult> {
if (!WEBHOOK_URL) return { ok: false, error: "N8N_PUBLISH_WEBHOOK_URL not set" };
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
let res: Response;
try {
res = await fetch(WEBHOOK_URL, {
method: "POST",
headers: {
"content-type": "application/json",
...(SECRET ? { "x-content-secret": SECRET } : {}),
},
body: JSON.stringify(req),
signal: controller.signal,
});
} catch (e) {
return { ok: false, error: `n8n unreachable: ${(e as Error).message}` };
} finally {
clearTimeout(timer);
}
const text = await res.text();
if (!res.ok) return { ok: false, error: `n8n ${res.status}: ${text.slice(0, 200)}` };
// n8n may wrap the Respond-to-Webhook body or return it bare.
try {
const parsed = JSON.parse(text || "{}");
const ok = parsed.ok === true || parsed.success === true;
const publishedUrl =
parsed.publishedUrl ?? parsed.url ?? parsed.postUrl ?? parsed.permalink ?? undefined;
if (!ok && !publishedUrl) {
return { ok: false, error: String(parsed.error ?? parsed.message ?? text.slice(0, 200)) };
}
return { ok: true, publishedUrl };
} catch {
// Non-JSON 2xx — treat as success but without a URL.
return { ok: true };
}
}

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

View File

@@ -312,19 +312,24 @@ export async function getTrialFunnel(days = 90): Promise<TrialFunnelRow[]> {
AND q.created_at <= coalesce(t.trial_end, now())
GROUP BY t.user_id, t.converted
)
SELECT
CASE
WHEN decode_count = 0 THEN '0'
WHEN decode_count BETWEEN 1 AND 2 THEN '1-2'
WHEN decode_count BETWEEN 3 AND 5 THEN '3-5'
WHEN decode_count BETWEEN 6 AND 10 THEN '6-10'
WHEN decode_count BETWEEN 11 AND 25 THEN '11-25'
ELSE '26+'
END AS bucket,
count(*) AS trial_users,
count(*) FILTER (WHERE converted = true) AS converted
FROM trial_decode_counts
GROUP BY bucket
SELECT bucket, trial_users, converted
FROM (
SELECT
CASE
WHEN decode_count = 0 THEN '0'
WHEN decode_count BETWEEN 1 AND 2 THEN '1-2'
WHEN decode_count BETWEEN 3 AND 5 THEN '3-5'
WHEN decode_count BETWEEN 6 AND 10 THEN '6-10'
WHEN decode_count BETWEEN 11 AND 25 THEN '11-25'
ELSE '26+'
END AS bucket,
count(*) AS trial_users,
count(*) FILTER (WHERE converted = true) AS converted
FROM trial_decode_counts
GROUP BY 1
) b
-- ORDER BY references the real subquery column bucket; Postgres won't
-- resolve a SELECT-list alias inside an expression in ORDER BY (42703).
ORDER BY
CASE bucket
WHEN '0' THEN 0

View File

@@ -12,7 +12,8 @@ export type AnomalyType =
| "p95_latency_spike"
| "volume_drop"
| "volume_spike"
| "timeout_dominance";
| "timeout_dominance"
| "unknown_vin_spike";
export type AnomalyHit = {
type: AnomalyType;
@@ -36,6 +37,7 @@ type WindowStats = {
p95: number | null;
avgMs: number | null;
timeouts: number;
unknownVins: number;
};
async function windowStats(start: Date, end: Date): Promise<WindowStats> {
@@ -47,6 +49,7 @@ async function windowStats(start: Date, end: Date): Promise<WindowStats> {
p95: number | null;
avg_ms: number | null;
timeouts: bigint;
unknown_vins: bigint;
}>
>`
SELECT
@@ -63,7 +66,16 @@ async function windowStats(start: Date, end: Date): Promise<WindowStats> {
OR error_message ILIKE '%timeout%'
OR (timings->>'aborted')::boolean = true
)
) AS timeouts
) AS timeouts,
count(*) FILTER (
WHERE success = false
AND (
error_message ILIKE '%unknown vin%'
OR error_message ILIKE '%tanınamad%'
OR error_message ILIKE '%destekl%'
OR (timings->>'result_kind') = 'unknown'
)
) AS unknown_vins
FROM query_logs
WHERE created_at >= ${start} AND created_at < ${end}
`;
@@ -74,6 +86,7 @@ async function windowStats(start: Date, end: Date): Promise<WindowStats> {
p95: null,
avg_ms: null,
timeouts: 0n,
unknown_vins: 0n,
};
const total = Number(r.total);
const succeeded = Number(r.succeeded);
@@ -87,6 +100,7 @@ async function windowStats(start: Date, end: Date): Promise<WindowStats> {
p95: r.p95,
avgMs: r.avg_ms,
timeouts: Number(r.timeouts),
unknownVins: Number(r.unknown_vins),
};
}
@@ -114,6 +128,7 @@ async function baselineStats(currentEnd: Date): Promise<WindowStats> {
p95: null,
avgMs: null,
timeouts: 0,
unknownVins: 0,
};
}
const total = samples.reduce((a, b) => a + b.total, 0);
@@ -122,6 +137,7 @@ async function baselineStats(currentEnd: Date): Promise<WindowStats> {
const p95Values = samples.map((s) => s.p95).filter((v): v is number => v != null);
const avgValues = samples.map((s) => s.avgMs).filter((v): v is number => v != null);
const timeouts = samples.reduce((a, b) => a + b.timeouts, 0);
const unknownVins = samples.reduce((a, b) => a + b.unknownVins, 0);
return {
total,
succeeded,
@@ -131,9 +147,37 @@ async function baselineStats(currentEnd: Date): Promise<WindowStats> {
p95: p95Values.length ? Math.round(p95Values.reduce((a, b) => a + b, 0) / p95Values.length) : null,
avgMs: avgValues.length ? Math.round(avgValues.reduce((a, b) => a + b, 0) / avgValues.length) : null,
timeouts,
unknownVins,
};
}
/**
* Attribution for an unknown-VIN / failure spike: which `source` dominates the
* failures in the window, and how many of those were aborted (budget/timeout).
* A proxy/decoder outage shows up here as a surge of source='none'/'aborted'
* or one provider's failures — turns "success dropped" into "EMEX is down".
*/
async function dominantFailingSource(start: Date, end: Date): Promise<string | null> {
const rows = await saseDb.$queryRaw<
Array<{ source: string | null; cnt: bigint; aborted: bigint }>
>`
SELECT
coalesce(source, 'none') AS source,
count(*) AS cnt,
count(*) FILTER (WHERE (timings->>'aborted')::boolean = true) AS aborted
FROM query_logs
WHERE created_at >= ${start} AND created_at < ${end} AND success = false
GROUP BY coalesce(source, 'none')
ORDER BY count(*) DESC
LIMIT 1
`;
const r = rows[0];
if (!r) return null;
const cnt = Number(r.cnt);
const aborted = Number(r.aborted);
return aborted > 0 ? `${r.source} (${cnt}, ${aborted} aborted)` : `${r.source} (${cnt})`;
}
export async function detectVinAnomalies(): Promise<{
current: WindowStats;
baseline: WindowStats;
@@ -243,6 +287,35 @@ export async function detectVinAnomalies(): Promise<{
});
}
// 6. Unknown-VIN spike ("tanınamadı" patlaması) — proxy/decoder outage. The
// generic success-rate-drop catches this too, but this names it explicitly
// and attributes the dominant failing source. Fire when the unknown-VIN
// share jumps ≥2× baseline, or surges from a near-zero baseline.
if (current.total >= MIN_CURRENT_VOLUME) {
const curRate = current.unknownVins / current.total;
const baseRate = baseline.total > 0 ? baseline.unknownVins / baseline.total : 0;
const spiked =
curRate >= 0.15 &&
(baseRate < 0.02 ? current.unknownVins >= 5 : curRate >= baseRate * 2);
if (spiked) {
const attribution = await dominantFailingSource(currentStart, now);
hits.push({
type: "unknown_vin_spike",
severity: curRate >= 0.4 ? "critical" : "high",
message:
`"Tanınamadı" oranı ${pct(baseRate)}${pct(curRate)} ` +
`(${current.unknownVins}/${current.total}, son ${CURRENT_WINDOW_MIN}dk)` +
(attribution ? ` · baskın source: ${attribution}` : ""),
baseline: baseRate,
observed: curRate,
current_volume: current.total,
baseline_volume: baseline.total,
detected_at: ts,
dedupe_key: `vin:unknown_vin_spike:${bucket}`,
});
}
}
return { current, baseline, anomalies: hits };
}

View File

@@ -166,6 +166,7 @@ export type ProviderAttemptRow = {
provider: string;
attemptCount: number;
avgMs: number;
p50Ms: number | null;
p95Ms: number | null;
};
@@ -175,12 +176,19 @@ export async function getProviderAttempts(range: TimeRange): Promise<ProviderAtt
const start = rangeStart(range);
const rows = await saseDb.$queryRaw<
Array<{ provider: string; cnt: bigint; avg_ms: number | null; p95_ms: number | null }>
Array<{
provider: string;
cnt: bigint;
avg_ms: number | null;
p50_ms: number | null;
p95_ms: number | null;
}>
>`
SELECT
key AS provider,
count(*) AS cnt,
avg((value::text)::numeric)::int AS avg_ms,
percentile_cont(0.50) WITHIN GROUP (ORDER BY (value::text)::numeric)::int AS p50_ms,
percentile_cont(0.95) WITHIN GROUP (ORDER BY (value::text)::numeric)::int AS p95_ms
FROM query_logs, jsonb_each(timings) AS j(key, value)
WHERE created_at >= ${start}
@@ -194,6 +202,7 @@ export async function getProviderAttempts(range: TimeRange): Promise<ProviderAtt
provider: r.provider,
attemptCount: Number(r.cnt),
avgMs: r.avg_ms ?? 0,
p50Ms: r.p50_ms,
p95Ms: r.p95_ms,
}));
}

View File

@@ -0,0 +1,161 @@
import { saseDb } from "@/lib/db-sase";
import { prisma } from "@/lib/db";
// Single-decode drill-down for the VIN list modal. Pulls the full query_logs
// row + parsed timings from the Sase RO DB, resolves the vehicle_id (so we can
// correlate frontend behaviour), then joins PostHog custom events from the
// PANEL DB (cross-database — two clients, not one SQL join).
export type DecodeTimings = {
// provider proxy times (ms) — present only when that provider was attempted
pcat?: number;
emex?: number;
pl24?: number;
vin_api?: number;
// race / lock: ms waited because another request was decoding the same VIN
lock_wait?: number;
// 'miss' | 'db_hit' | 'redis_positive' | 'lock_wait' | 'redis_negative' ...
cache_source?: string;
cache_neg_hit?: boolean;
// candidate disambiguation counts returned by each provider
pcat_car_count?: number;
emex_candidate_count?: number;
candidate_pick?: string; // 'none' | 'auto' | 'single' ...
pl24_circuit_open?: boolean;
aborted?: boolean;
result_kind?: string; // 'vehicle' | 'unknown' ...
wmi?: string;
[k: string]: unknown;
};
export type VinDecodeDetail = {
id: string;
vin: string;
userId: string;
userEmail: string | null;
userName: string | null;
brandSlug: string | null;
brandName: string | null;
source: string | null;
success: boolean;
responseTimeMs: number | null;
errorMessage: string | null;
createdAt: Date;
vehicleId: string | null;
timings: DecodeTimings | null;
};
export async function getVinDecodeDetail(id: string): Promise<VinDecodeDetail | null> {
const rows = await saseDb.$queryRaw<
Array<{
id: string;
vin: string;
user_id: string;
user_email: string | null;
user_name: string | null;
brand_slug: string | null;
brand_name: string | null;
source: string | null;
success: boolean;
response_time_ms: number | null;
error_message: string | null;
timings: DecodeTimings | null;
created_at: Date;
vehicle_id: string | null;
}>
>`
SELECT
q.id, q.vin, q.user_id,
u.email AS user_email, u.name AS user_name,
b.slug AS brand_slug, b.name AS brand_name,
q.source, q.success, q.response_time_ms, q.error_message,
q.timings, q.created_at,
v.id AS vehicle_id
FROM query_logs q
LEFT JOIN users u ON u.id = q.user_id
LEFT JOIN brands b ON b.id = q.brand_id
LEFT JOIN vehicles v ON v.vin = q.vin
WHERE q.id = ${id}::uuid
LIMIT 1
`;
const r = rows[0];
if (!r) return null;
return {
id: r.id,
vin: r.vin,
userId: r.user_id,
userEmail: r.user_email,
userName: r.user_name,
brandSlug: r.brand_slug,
brandName: r.brand_name,
source: r.source,
success: r.success,
responseTimeMs: r.response_time_ms,
errorMessage: r.error_message,
createdAt: r.created_at,
vehicleId: r.vehicle_id,
timings: r.timings,
};
}
export type VinUserBehavior = {
oemCopiedCount: number;
oemCodes: string[];
categoriesOpened: number;
partsPanelViews: number;
firstAt: Date | null;
lastAt: Date | null;
};
// Correlate post-decode frontend behaviour for THIS vehicle. PostHog custom
// events live in the panel DB (session_custom_events). They carry the Sase
// user uuid (distinct_id / $user_id) and the vehicle_id, so we scope to this
// user + vehicle. Returns null when the decode produced no vehicle row.
export async function getVinUserBehavior(
userId: string,
vehicleId: string | null,
): Promise<VinUserBehavior | null> {
if (!vehicleId) return null;
const [agg] = await prisma.$queryRaw<
Array<{
oem_copied: bigint;
categories_opened: bigint;
parts_views: bigint;
first_at: Date | null;
last_at: Date | null;
}>
>`
SELECT
count(*) FILTER (WHERE "eventName" = 'oem_code_copied') AS oem_copied,
count(DISTINCT (properties->>'category_id'))
FILTER (WHERE "eventName" = 'parts_panel_viewed'
AND properties->>'category_id' IS NOT NULL) AS categories_opened,
count(*) FILTER (WHERE "eventName" = 'parts_panel_viewed') AS parts_views,
min("timestamp") AS first_at,
max("timestamp") AS last_at
FROM session_custom_events
WHERE properties->>'vehicle_id' = ${vehicleId}
AND (properties->>'distinct_id' = ${userId} OR properties->>'$user_id' = ${userId})
AND "eventName" IN ('oem_code_copied', 'parts_panel_viewed')
`;
const codeRows = await prisma.$queryRaw<Array<{ code: string }>>`
SELECT DISTINCT properties->>'oem_code' AS code
FROM session_custom_events
WHERE "eventName" = 'oem_code_copied'
AND properties->>'vehicle_id' = ${vehicleId}
AND (properties->>'distinct_id' = ${userId} OR properties->>'$user_id' = ${userId})
AND properties->>'oem_code' IS NOT NULL
LIMIT 20
`;
return {
oemCopiedCount: Number(agg?.oem_copied ?? 0n),
oemCodes: codeRows.map((r) => r.code),
categoriesOpened: Number(agg?.categories_opened ?? 0n),
partsPanelViews: Number(agg?.parts_views ?? 0n),
firstAt: agg?.first_at ?? null,
lastAt: agg?.last_at ?? null,
};
}

View File

@@ -164,6 +164,87 @@ export async function getUnderSupportedBrands(
});
}
// ─── WMI opportunity radar — systematizes "which WMI to add next" (Faz 4) ──
// WMI = World Manufacturer Identifier = first 3 chars of the VIN (ISO 3779).
// Grouping by substring(vin,1,3) works on 100% of historical rows regardless
// of whether timings.wmi has been backfilled. We surface WMIs with meaningful
// demand but low decode success — ranked by *distinct users* (real demand,
// not one user retrying). `brandMatched = 0` means the WMI never resolved to
// any brand at all → a fully-uncovered manufacturer code (strongest "add this"
// signal). result_kind (when present in timings) labels the dominant outcome.
export type WmiOpportunity = {
wmi: string;
total: number;
succeeded: number;
failed: number;
successRate: number;
uniqueUsers: number;
brandMatched: number; // how many of these requests carried a brand_id
topBrandSlug: string | null;
topBrandName: string | null;
topResultKind: string | null;
};
export async function getWmiOpportunities(
days = 30,
minVolume = 15,
maxSuccessRate = 0.7,
): Promise<WmiOpportunity[]> {
const start = new Date(Date.now() - days * 24 * 60 * 60_000);
const rows = await saseDb.$queryRaw<
Array<{
wmi: string;
total: bigint;
succeeded: bigint;
failed: bigint;
users: bigint;
brand_matched: bigint;
top_brand_slug: string | null;
top_brand_name: string | null;
top_result_kind: string | null;
}>
>`
SELECT
substring(q.vin from 1 for 3) AS wmi,
count(*) AS total,
count(*) FILTER (WHERE q.success = true) AS succeeded,
count(*) FILTER (WHERE q.success = false) AS failed,
count(DISTINCT q.user_id) AS users,
count(*) FILTER (WHERE q.brand_id IS NOT NULL) AS brand_matched,
mode() WITHIN GROUP (ORDER BY b.slug)
FILTER (WHERE b.slug IS NOT NULL) AS top_brand_slug,
mode() WITHIN GROUP (ORDER BY b.name)
FILTER (WHERE b.name IS NOT NULL) AS top_brand_name,
mode() WITHIN GROUP (ORDER BY (q.timings->>'result_kind'))
FILTER (WHERE (q.timings->>'result_kind') IS NOT NULL) AS top_result_kind
FROM query_logs q
LEFT JOIN brands b ON b.id = q.brand_id
WHERE q.created_at >= ${start}
AND char_length(q.vin) >= 3
GROUP BY substring(q.vin from 1 for 3)
HAVING count(*) >= ${minVolume}
AND count(*) FILTER (WHERE q.success = true)::float / count(*) <= ${maxSuccessRate}
ORDER BY count(DISTINCT q.user_id) DESC, count(*) FILTER (WHERE q.success = false) DESC
LIMIT 30
`;
return rows.map((r) => {
const total = Number(r.total);
const succeeded = Number(r.succeeded);
return {
wmi: r.wmi,
total,
succeeded,
failed: Number(r.failed),
successRate: total > 0 ? succeeded / total : 0,
uniqueUsers: Number(r.users),
brandMatched: Number(r.brand_matched),
topBrandSlug: r.top_brand_slug,
topBrandName: r.top_brand_name,
topResultKind: r.top_result_kind,
};
});
}
// ─── Peak heatmap (hour-of-day × day-of-week, last 30d) ───────────────────
export type HeatmapCell = {
dayOfWeek: number; // 0 = Mon, 6 = Sun (ISO style)
@@ -318,9 +399,9 @@ export type ProviderDeepStats = {
provider: string;
range: "7d" | "30d";
// Won-by-this-provider window
wins: { total: number; succeeded: number; successRate: number; avgMs: number | null; p95: number | null };
wins: { total: number; succeeded: number; successRate: number; avgMs: number | null; p50: number | null; p95: number | null };
// Attempted-in-chain window (timings key present)
attempts: { total: number; avgMs: number; p95: number | null };
attempts: { total: number; avgMs: number; p50: number | null; p95: number | null };
// Daily win volume for the window
dailyVolume: Array<{ date: string; count: number }>;
topBrands: Array<{ brandSlug: string | null; brandName: string | null; count: number }>;
@@ -360,6 +441,7 @@ export async function getProviderDeepStats(
total: bigint;
succeeded: bigint;
avg_ms: number | null;
p50: number | null;
p95: number | null;
}>
>`
@@ -367,6 +449,7 @@ export async function getProviderDeepStats(
count(*) AS total,
count(*) FILTER (WHERE success = true) AS succeeded,
avg(response_time_ms)::int AS avg_ms,
percentile_cont(0.50) WITHIN GROUP (ORDER BY response_time_ms)::int AS p50,
percentile_cont(0.95) WITHIN GROUP (ORDER BY response_time_ms)::int AS p95
FROM query_logs
WHERE created_at >= ${start} AND source = ${provider}
@@ -381,14 +464,15 @@ export async function getProviderDeepStats(
// Attempts: timings key present (provider showed up in the chain even if it
// didn't win). Some providers have no timings key (e.g. 'cache'), so this
// can be empty.
let attempts = { total: 0, avgMs: 0, p95: null as number | null };
let attempts = { total: 0, avgMs: 0, p50: null as number | null, p95: null as number | null };
if (timingsKey) {
const [attemptsRow] = await saseDb.$queryRaw<
Array<{ total: bigint; avg_ms: number | null; p95_ms: number | null }>
Array<{ total: bigint; avg_ms: number | null; p50_ms: number | null; p95_ms: number | null }>
>`
SELECT
count(*) AS total,
avg((timings->>${timingsKey})::numeric)::int AS avg_ms,
percentile_cont(0.50) WITHIN GROUP (ORDER BY (timings->>${timingsKey})::numeric)::int AS p50_ms,
percentile_cont(0.95) WITHIN GROUP (ORDER BY (timings->>${timingsKey})::numeric)::int AS p95_ms
FROM query_logs
WHERE created_at >= ${start}
@@ -399,6 +483,7 @@ export async function getProviderDeepStats(
attempts = {
total: Number(attemptsRow.total),
avgMs: attemptsRow.avg_ms ?? 0,
p50: attemptsRow.p50_ms,
p95: attemptsRow.p95_ms,
};
}
@@ -514,6 +599,7 @@ export async function getProviderDeepStats(
succeeded: winsSucceeded,
successRate: winsTotal > 0 ? winsSucceeded / winsTotal : 0,
avgMs: winsRow?.avg_ms ?? null,
p50: winsRow?.p50 ?? null,
p95: winsRow?.p95 ?? null,
},
attempts,

View File

@@ -1,6 +1,7 @@
import { startEventBus } from "./consumers/event-bus";
import { startScheduledJobs } from "./schedulers/nightly";
import { startInsightPipeline } from "./schedulers/pipeline";
import { startContentPipeline } from "./schedulers/content";
import { upsertSeedData } from "./lib/seed-runtime";
import { redis } from "./redis";
import { prisma } from "./db";
@@ -16,6 +17,7 @@ async function main() {
await startScheduledJobs();
await startInsightPipeline();
await startContentPipeline();
await startEventBus();
console.log("[worker] up.");

View File

@@ -0,0 +1,114 @@
import { gzipSync } from "node:zlib";
import { prisma } from "../db";
import { getSnapshotSources, getSnapshotBlob, isConfigured } from "../lib/posthog";
import { putBuffer } from "../lib/minio";
// Archive every recording's raw rrweb before PostHog's ~30-day deletion. Runs over
// ALL recordings (incl. discarded ones) at full fidelity — unlike compress, which
// only handles scored sessions and caps blobs for token budget.
const RRWEB_BUCKET = process.env.RRWEB_ARCHIVE_BUCKET ?? "rrweb-archive";
const BATCH = Number(process.env.INSIGHT_RECORDING_ARCHIVE_BATCH ?? "60");
const MAX_AGE_DAYS = Number(process.env.INSIGHT_RECORDING_ARCHIVE_MAX_AGE_DAYS ?? "25");
// PostHog's snapshot API is aggressively rate-limited. Pace requests and bail out
// of the run on sustained 429s — unarchived rows keep rrwebArchivedAt=null and are
// retried next cycle (the 25d window vs */6h cadence gives ample slack).
const RATE_DELAY_MS = Number(process.env.INSIGHT_RECORDING_ARCHIVE_DELAY_MS ?? "500");
// blob_v2 rejects wide blob-key ranges (a 23-key span 400s, a 5-key span is fine),
// so fetch in small contiguous chunks and concatenate.
const BLOB_CHUNK = Number(process.env.INSIGHT_RECORDING_BLOB_CHUNK ?? "10");
const CHUNK_DELAY_MS = Number(process.env.INSIGHT_RECORDING_CHUNK_DELAY_MS ?? "150");
export type RecordingArchiveResult = {
scanned: number;
archived: number;
failed: number;
skipped: number;
rateLimited: boolean;
};
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const isRateLimit = (e: unknown) => (e as Error)?.message?.includes(" 429");
export async function runArchiveRecordings(): Promise<RecordingArchiveResult> {
if (!isConfigured()) return { scanned: 0, archived: 0, failed: 0, skipped: 0, rateLimited: false };
const floor = new Date(Date.now() - MAX_AGE_DAYS * 24 * 3600_000);
const pending = await prisma.sessionMeta.findMany({
where: { rrwebArchivedAt: null, startedAt: { gt: floor } },
orderBy: { startedAt: "asc" },
take: BATCH,
});
if (pending.length === 0) return { scanned: 0, archived: 0, failed: 0, skipped: 0, rateLimited: false };
let archived = 0;
let failed = 0;
let skipped = 0;
let rateLimited = false;
let processed = 0;
for (const s of pending) {
if (processed > 0) await sleep(RATE_DELAY_MS);
processed++;
try {
const sources = await getSnapshotSources(s.id);
if (sources.length === 0) {
// No snapshots (too short / already purged) — mark done so we stop retrying.
await prisma.sessionMeta.update({ where: { id: s.id }, data: { rrwebArchivedAt: new Date() } });
skipped++;
continue;
}
// Fetch ALL blobs per source (no 12-cap), chunked to dodge blob_v2's range limit.
const bySource = new Map<string, string[]>();
for (const src of sources) {
if (!bySource.has(src.source)) bySource.set(src.source, []);
bySource.get(src.source)!.push(src.blob_key);
}
const parts: string[] = [];
for (const [source, keys] of bySource) {
const sorted = [...keys].sort((a, b) => Number(a) - Number(b));
for (let i = 0; i < sorted.length; i += BLOB_CHUNK) {
if (i > 0) await sleep(CHUNK_DELAY_MS);
const lo = sorted[i];
const hi = sorted[Math.min(i + BLOB_CHUNK - 1, sorted.length - 1)];
const blob = await getSnapshotBlob(s.id, source, lo, hi);
if (blob) parts.push(blob);
}
}
const raw = parts.join("\n").trim();
if (!raw) {
await prisma.sessionMeta.update({ where: { id: s.id }, data: { rrwebArchivedAt: new Date() } });
skipped++;
continue;
}
const key = `${s.projectKey}/${dateFolder(s.startedAt)}/${s.id}.jsonl.gz`;
const gz = gzipSync(Buffer.from(raw, "utf-8"));
await putBuffer(RRWEB_BUCKET, key, gz, "application/gzip");
await prisma.sessionMeta.update({
where: { id: s.id },
data: { rrwebArchivedAt: new Date(), rrwebArchiveKey: key },
});
archived++;
} catch (e) {
if (isRateLimit(e)) {
// Rate limited — stop hammering. Remaining rows retry next cycle.
rateLimited = true;
break;
}
// Other transient errors — leave rrwebArchivedAt null so it retries next run.
console.warn("[archive-recordings] failed", s.id, (e as Error).message);
failed++;
}
}
return { scanned: pending.length, archived, failed, skipped, rateLimited };
}
function dateFolder(d: Date): string {
const y = d.getUTCFullYear();
const m = String(d.getUTCMonth() + 1).padStart(2, "0");
const day = String(d.getUTCDate()).padStart(2, "0");
return `${y}/${m}/${day}`;
}

View File

@@ -0,0 +1,180 @@
// content-generate job (Phase 8a): turns queued ContentTopic rows into one
// ContentDraft per selected channel. Mirrors the insight analyze job:
// budget guard → per-call DeepSeek → JSON validate → persist + cost ledger.
// Drafts land in `draft` status; the founder reviews/edits and (Phase 8c)
// publishes via the n8n webhook. Nothing is published from here.
import { prisma } from "../db";
import { callDeepSeek, extractJson, type Tier, DeepSeekError } from "../lib/deepseek";
import { checkContentBudget } from "../lib/content-budget";
import { channelPromptTag, type ContentChannel } from "../lib/content-prompts";
import { validate } from "../lib/json-validate";
const PROJECT_KEY = process.env.CONTENT_PROJECT_KEY ?? "sase";
const GENERATE_BATCH = Number(process.env.CONTENT_GENERATE_BATCH ?? "3");
export type ContentGenerateResult = {
topics: number;
draftsCreated: number;
failed: number;
costUsd: number;
budgetState: string;
reason?: string;
};
export async function runContentGenerate(): Promise<ContentGenerateResult> {
const budget = await checkContentBudget();
if (!budget.allow) {
return { topics: 0, draftsCreated: 0, failed: 0, costUsd: 0, budgetState: budget.state, reason: budget.reason };
}
const topics = await prisma.contentTopic.findMany({
where: { projectKey: PROJECT_KEY, status: "queued" },
orderBy: { createdAt: "asc" },
take: GENERATE_BATCH,
});
if (topics.length === 0) {
return { topics: 0, draftsCreated: 0, failed: 0, costUsd: 0, budgetState: budget.state };
}
let draftsCreated = 0;
let failed = 0;
let costUsd = 0;
for (const topic of topics) {
await prisma.contentTopic.update({ where: { id: topic.id }, data: { status: "generating" } });
const channels = (topic.channels as string[]).filter((c) =>
["blog", "linkedin", "x", "instagram"].includes(c),
) as ContentChannel[];
for (const channel of channels) {
// Skip if a draft for this topic+channel already exists (idempotent reruns).
const existing = await prisma.contentDraft.findFirst({
where: { topicId: topic.id, channel },
select: { id: true },
});
if (existing) continue;
const tag = channelPromptTag(channel);
const template = await prisma.promptTemplate.findFirst({
where: { tag, active: true },
orderBy: { version: "desc" },
});
if (!template) {
console.warn(`[content-generate] no prompt for ${tag}`);
continue;
}
const tier: Tier = budget.forceTier ?? (template.modelTier as Tier);
const userPrompt = template.userPromptTemplate
.replace("{{title}}", topic.title)
.replace("{{brief}}", topic.brief ?? "")
.replace("{{angle}}", topic.angle ?? "(belirtilmedi)")
.replace("{{keywords}}", (topic.keywords as string[]).join(", ") || "(yok)");
let result;
try {
result = await callDeepSeek({
tier,
systemPrompt: template.systemPrompt,
userPrompt,
maxOutputTokens: template.maxOutputTokens,
temperature: template.temperature,
});
} catch (e) {
const status = e instanceof DeepSeekError ? e.status : 0;
console.warn(`[content-generate] deepseek error topic=${topic.id} ${channel}: ${status}`);
await logCost({ tag, tier, promptVersion: template.version, errorCode: `${status}` });
failed++;
continue;
}
costUsd += result.cost.totalUsd;
let parsed: any;
let validationErrors = "";
try {
parsed = JSON.parse(extractJson(result.text));
const errs = validate(parsed, template.outputSchemaJson as any);
if (errs.length) validationErrors = errs.map((e) => `${e.path}: ${e.message}`).join("; ");
} catch (e) {
validationErrors = `json parse: ${(e as Error).message}`;
}
await logCost({
tag,
tier,
promptVersion: template.version,
usage: result.usage,
cost: result.cost,
model: result.model,
durationMs: result.durationMs,
errorCode: validationErrors ? "validation_failed" : undefined,
});
if (validationErrors) {
console.warn(`[content-generate] validation failed topic=${topic.id} ${channel}: ${validationErrors.slice(0, 160)}`);
failed++;
continue;
}
await prisma.contentDraft.create({
data: {
topicId: topic.id,
projectKey: PROJECT_KEY,
channel,
status: "draft",
bodyJson: parsed,
sourcePromptTag: tag,
sourcePromptVersion: template.version,
sourceModel: result.model,
sourceCostUsd: result.cost.totalUsd,
},
});
draftsCreated++;
}
await prisma.contentTopic.update({ where: { id: topic.id }, data: { status: "drafted" } });
const recheck = await checkContentBudget();
if (!recheck.allow) {
console.log(`[content-generate] budget exhausted mid-batch (${recheck.state})`);
break;
}
}
return { topics: topics.length, draftsCreated, failed, costUsd, budgetState: budget.state };
}
async function logCost(opts: {
tag: string;
tier: Tier;
promptVersion: number;
usage?: { inputTokensMiss: number; inputTokensHit: number; outputTokens: number };
cost?: { inputMissUsd: number; inputHitUsd: number; outputUsd: number; totalUsd: number; cacheHitRatio: number };
model?: string;
durationMs?: number;
errorCode?: string;
}): Promise<void> {
await prisma.costLedger.create({
data: {
projectKey: PROJECT_KEY,
promptTag: opts.tag,
promptVersion: opts.promptVersion,
provider: "deepseek",
model: opts.model ?? (opts.tier === "pro" ? "deepseek-v4-pro" : "deepseek-v4-flash"),
tier: opts.tier,
tokensInputCacheMiss: opts.usage?.inputTokensMiss ?? 0,
tokensInputCacheHit: opts.usage?.inputTokensHit ?? 0,
tokensOutput: opts.usage?.outputTokens ?? 0,
costInputCacheMissUsd: opts.cost?.inputMissUsd ?? 0,
costInputCacheHitUsd: opts.cost?.inputHitUsd ?? 0,
costOutputUsd: opts.cost?.outputUsd ?? 0,
costTotalUsd: opts.cost?.totalUsd ?? 0,
cacheHitRatio: opts.cost?.cacheHitRatio ?? 0,
callDurationMs: opts.durationMs ?? null,
errorCode: opts.errorCode ?? null,
},
});
}

View File

@@ -0,0 +1,204 @@
// content-topics job (Phase 8a): auto-generates content topic ideas via the
// LLM and queues them as ContentTopic rows. Conservative by design — it only
// tops the backlog up to a target, dedupes near-identical titles, and is
// gated by the separate content budget.
import { prisma } from "../db";
import { callDeepSeek, extractJson, type Tier, DeepSeekError } from "../lib/deepseek";
import { checkContentBudget } from "../lib/content-budget";
import { validate } from "../lib/json-validate";
import { fingerprintHash } from "../lib/hash";
const PROJECT_KEY = process.env.CONTENT_PROJECT_KEY ?? "sase";
// Stop generating new ideas once this many topics are already waiting.
const BACKLOG_TARGET = Number(process.env.CONTENT_BACKLOG_TARGET ?? "12");
const DEFAULT_CHANNELS = (process.env.CONTENT_DEFAULT_CHANNELS ?? "blog,linkedin,x,instagram")
.split(",")
.map((c) => c.trim())
.filter(Boolean);
type TopicIdea = {
title: string;
brief: string;
angle?: string;
channels?: string[];
keywords?: string[];
};
export type ContentTopicsResult = {
generated: number;
inserted: number;
skipped: number;
costUsd: number;
budgetState: string;
reason?: string;
};
export async function runContentTopics(): Promise<ContentTopicsResult> {
const budget = await checkContentBudget();
if (!budget.allow) {
return { generated: 0, inserted: 0, skipped: 0, costUsd: 0, budgetState: budget.state, reason: budget.reason };
}
// Only top up the backlog — don't generate endlessly.
const queuedCount = await prisma.contentTopic.count({
where: { projectKey: PROJECT_KEY, status: { in: ["queued", "generating"] } },
});
if (queuedCount >= BACKLOG_TARGET) {
return { generated: 0, inserted: 0, skipped: 0, budgetState: budget.state, costUsd: 0, reason: "backlog_full" };
}
const want = Math.min(6, BACKLOG_TARGET - queuedCount);
const template = await prisma.promptTemplate.findFirst({
where: { tag: "content_topic_ideas", active: true },
orderBy: { version: "desc" },
});
if (!template) {
return { generated: 0, inserted: 0, skipped: 0, budgetState: budget.state, costUsd: 0, reason: "no_prompt" };
}
// Recent topics so the model avoids repeating itself.
const recent = await prisma.contentTopic.findMany({
where: { projectKey: PROJECT_KEY },
select: { title: true },
orderBy: { createdAt: "desc" },
take: 40,
});
const context =
recent.length > 0
? `Son üretilen konular (BUNLARA BENZER üretme):\n${recent.map((r) => `- ${r.title}`).join("\n")}`
: "Henüz üretilmiş konu yok.";
const tier: Tier = budget.forceTier ?? (template.modelTier as Tier);
const userPrompt = template.userPromptTemplate
.replace("{{context}}", context)
.replace("{{count}}", String(want));
let result;
try {
result = await callDeepSeek({
tier,
systemPrompt: template.systemPrompt,
userPrompt,
maxOutputTokens: template.maxOutputTokens,
temperature: template.temperature,
});
} catch (e) {
const status = e instanceof DeepSeekError ? e.status : 0;
await logCost({ tier, promptVersion: template.version, errorCode: `${status}` });
return {
generated: 0,
inserted: 0,
skipped: 0,
budgetState: budget.state,
costUsd: 0,
reason: `deepseek ${status}: ${(e as Error).message}`,
};
}
await logCost({
tier,
promptVersion: template.version,
usage: result.usage,
cost: result.cost,
model: result.model,
durationMs: result.durationMs,
});
let parsed: any;
try {
parsed = JSON.parse(extractJson(result.text));
} catch (e) {
return {
generated: 0,
inserted: 0,
skipped: 0,
budgetState: budget.state,
costUsd: result.cost.totalUsd,
reason: `json parse: ${(e as Error).message}`,
};
}
const errs = validate(parsed, template.outputSchemaJson as any);
if (errs.length) {
return {
generated: 0,
inserted: 0,
skipped: 0,
budgetState: budget.state,
costUsd: result.cost.totalUsd,
reason: `validation: ${errs.map((e) => e.path).join(",").slice(0, 120)}`,
};
}
const ideas: TopicIdea[] = Array.isArray(parsed.topics) ? parsed.topics : [];
let inserted = 0;
let skipped = 0;
for (const idea of ideas) {
const fp = fingerprintHash([PROJECT_KEY, idea.title]);
const dupe = await prisma.contentTopic.findFirst({ where: { projectKey: PROJECT_KEY, fingerprint: fp } });
if (dupe) {
skipped++;
continue;
}
const channels = (idea.channels && idea.channels.length ? idea.channels : DEFAULT_CHANNELS).filter((c) =>
["blog", "linkedin", "x", "instagram"].includes(c),
);
await prisma.contentTopic.create({
data: {
projectKey: PROJECT_KEY,
title: idea.title.slice(0, 250),
brief: idea.brief ?? "",
angle: idea.angle ?? null,
channels: channels.length ? channels : DEFAULT_CHANNELS,
keywords: Array.isArray(idea.keywords) ? idea.keywords.slice(0, 12) : [],
status: "queued",
source: "auto",
fingerprint: fp,
sourcePromptTag: "content_topic_ideas",
sourcePromptVersion: template.version,
sourceModel: result.model,
sourceCostUsd: result.cost.totalUsd / Math.max(1, ideas.length),
},
});
inserted++;
}
return {
generated: ideas.length,
inserted,
skipped,
budgetState: budget.state,
costUsd: result.cost.totalUsd,
};
}
async function logCost(opts: {
tier: Tier;
promptVersion: number;
usage?: { inputTokensMiss: number; inputTokensHit: number; outputTokens: number };
cost?: { inputMissUsd: number; inputHitUsd: number; outputUsd: number; totalUsd: number; cacheHitRatio: number };
model?: string;
durationMs?: number;
errorCode?: string;
}): Promise<void> {
await prisma.costLedger.create({
data: {
projectKey: PROJECT_KEY,
promptTag: "content_topic_ideas",
promptVersion: opts.promptVersion,
provider: "deepseek",
model: opts.model ?? (opts.tier === "pro" ? "deepseek-v4-pro" : "deepseek-v4-flash"),
tier: opts.tier,
tokensInputCacheMiss: opts.usage?.inputTokensMiss ?? 0,
tokensInputCacheHit: opts.usage?.inputTokensHit ?? 0,
tokensOutput: opts.usage?.outputTokens ?? 0,
costInputCacheMissUsd: opts.cost?.inputMissUsd ?? 0,
costInputCacheHitUsd: opts.cost?.inputHitUsd ?? 0,
costOutputUsd: opts.cost?.outputUsd ?? 0,
costTotalUsd: opts.cost?.totalUsd ?? 0,
cacheHitRatio: opts.cost?.cacheHitRatio ?? 0,
callDurationMs: opts.durationMs ?? null,
errorCode: opts.errorCode ?? null,
},
});
}

View File

@@ -0,0 +1,202 @@
import { gzipSync } from "node:zlib";
import { prisma } from "../db";
import { hogqlQuery, isConfigured } from "../lib/posthog";
import { putBuffer } from "../lib/minio";
const PROJECT_KEY = process.env.INSIGHT_PROJECT_KEY ?? "sase";
// Separate watermark stream so it never collides with the recording-ingest watermark (PROJECT_KEY).
const WM_KEY = `${PROJECT_KEY}:events`;
const BACKFILL_DAYS = Number(process.env.INSIGHT_EVENT_BACKFILL_DAYS ?? "365");
const BATCH_SIZE = Number(process.env.INSIGHT_EVENT_BATCH ?? "500");
const MAX_PAGES = Number(process.env.INSIGHT_EVENT_MAX_PAGES ?? "20");
const MAX_DUMP_DAYS = Number(process.env.INSIGHT_EVENT_MAX_DUMP_DAYS ?? "120");
const ARCHIVE_BUCKET = process.env.INSIGHT_ARCHIVE_BUCKET ?? "posthog-archive";
export type EventArchiveResult = {
fetched: number;
inserted: number;
duplicates: number;
pages: number;
cursorAdvancedTo: string | null;
dumped: number;
error?: string;
};
type EventRow = {
uuid: string;
projectKey: string;
event: string;
distinctId: string;
personId: string | null;
sessionId: string | null;
timestamp: Date;
properties: unknown;
};
// Coerce HogQL's properties cell (object or JSON string) into a plain object.
function parseProps(v: unknown): unknown {
if (v && typeof v === "object") return v;
if (typeof v === "string" && v) {
try {
return JSON.parse(v);
} catch {
return {};
}
}
return {};
}
function emptyOrNull(v: unknown): string | null {
const s = v == null ? "" : String(v);
return s ? s : null;
}
export async function runArchivePosthogEvents(): Promise<EventArchiveResult> {
if (!isConfigured()) {
return { fetched: 0, inserted: 0, duplicates: 0, pages: 0, cursorAdvancedTo: null, dumped: 0, error: "posthog_not_configured" };
}
const now = new Date();
const wm = await prisma.ingestionWatermark.findUnique({ where: { projectKey: WM_KEY } });
let cursor = wm?.posthogCursor
? new Date(wm.posthogCursor)
: new Date(now.getTime() - BACKFILL_DAYS * 24 * 3600_000);
let fetched = 0;
let inserted = 0;
let duplicates = 0;
let pages = 0;
let error: string | undefined;
for (let page = 0; page < MAX_PAGES; page++) {
let resp;
try {
// properties.$session_id is the 5th column; full properties is the 7th.
// parseDateTimeBestEffort is required — ClickHouse can't compare timestamp
// against a raw ISO8601 string literal (the trailing Z / ms 500s the query).
resp = await hogqlQuery(
`SELECT uuid, event, distinct_id, person_id, properties.$session_id, timestamp, properties
FROM events
WHERE timestamp >= parseDateTimeBestEffort('${cursor.toISOString()}')
ORDER BY timestamp ASC
LIMIT ${BATCH_SIZE}`,
);
} catch (e) {
error = (e as Error).message;
break;
}
pages++;
const rows = resp.results ?? [];
if (rows.length === 0) break;
const batch: EventRow[] = [];
let maxTs = cursor;
for (const r of rows) {
const uuid = String(r[0] ?? "");
if (!uuid) continue;
const ts = new Date(String(r[5]));
if (ts > maxTs) maxTs = ts;
batch.push({
uuid,
projectKey: PROJECT_KEY,
event: String(r[1] ?? ""),
distinctId: String(r[2] ?? ""),
personId: emptyOrNull(r[3]),
sessionId: emptyOrNull(r[4]),
timestamp: ts,
properties: parseProps(r[6]) as object,
});
}
fetched += batch.length;
if (batch.length) {
const res = await prisma.posthogEvent.createMany({
data: batch as never,
skipDuplicates: true,
});
inserted += res.count;
duplicates += batch.length - res.count;
}
// Advance cursor to the batch's max timestamp. Using >= on the next query
// re-reads same-second events, but uuid PK + skipDuplicates absorbs them.
if (maxTs > cursor) {
cursor = maxTs;
} else {
// Safety valve: a full all-duplicate batch with no time progress would
// otherwise loop forever. Nudge cursor past it.
cursor = new Date(cursor.getTime() + 1);
}
if (rows.length < BATCH_SIZE) break;
}
// Persist cursor.
await prisma.ingestionWatermark.upsert({
where: { projectKey: WM_KEY },
create: { projectKey: WM_KEY, lastPolledAt: now, posthogCursor: cursor.toISOString() },
update: { lastPolledAt: now, posthogCursor: cursor.toISOString() },
});
// Cold dump: write closed (before-today, UTC) days to MinIO as gzip JSONL.
let dumped = 0;
try {
dumped = await dumpClosedDays(now);
} catch (e) {
error = error ?? `dump_failed: ${(e as Error).message}`;
}
return { fetched, inserted, duplicates, pages, cursorAdvancedTo: cursor.toISOString(), dumped, error };
}
// Groups undumped rows (timestamp < start-of-today-UTC) by UTC day, writes the
// full day as one gzip JSONL object, then marks those rows dumped. Re-running is
// deterministic: each closed day maps to exactly one object that is overwritten.
async function dumpClosedDays(now: Date): Promise<number> {
const todayStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
const undumped = await prisma.posthogEvent.findMany({
where: { projectKey: PROJECT_KEY, dumpedAt: null, timestamp: { lt: todayStart } },
select: { timestamp: true },
orderBy: { timestamp: "asc" },
});
if (undumped.length === 0) return 0;
const days = Array.from(new Set(undumped.map((r) => r.timestamp.toISOString().slice(0, 10)))).slice(0, MAX_DUMP_DAYS);
let dumped = 0;
for (const day of days) {
const dayStart = new Date(`${day}T00:00:00.000Z`);
const dayEnd = new Date(dayStart.getTime() + 24 * 3600_000);
const rows = await prisma.posthogEvent.findMany({
where: { projectKey: PROJECT_KEY, timestamp: { gte: dayStart, lt: dayEnd } },
orderBy: { timestamp: "asc" },
});
if (rows.length === 0) continue;
const jsonl = rows
.map((r) =>
JSON.stringify({
uuid: r.uuid,
event: r.event,
distinctId: r.distinctId,
personId: r.personId,
sessionId: r.sessionId,
timestamp: r.timestamp.toISOString(),
properties: r.properties,
}),
)
.join("\n");
const gz = gzipSync(Buffer.from(jsonl, "utf-8"));
const [y, m, d] = day.split("-");
const key = `${PROJECT_KEY}/${y}/${m}/${d}.jsonl.gz`;
await putBuffer(ARCHIVE_BUCKET, key, gz, "application/gzip");
const marked = await prisma.posthogEvent.updateMany({
where: { projectKey: PROJECT_KEY, dumpedAt: null, timestamp: { gte: dayStart, lt: dayEnd } },
data: { dumpedAt: new Date() },
});
dumped += marked.count;
}
return dumped;
}

View File

@@ -0,0 +1,97 @@
import { prisma } from "../db";
import { listPersons, listCohorts, isConfigured } from "../lib/posthog";
import { stableHash } from "../lib/hash";
const PROJECT_KEY = process.env.INSIGHT_PROJECT_KEY ?? "sase";
export type IdentityArchiveResult = {
personsScanned: number;
personSnapshots: number;
cohortsScanned: number;
cohortSnapshots: number;
error?: string;
};
// Daily append-only snapshot of PostHog persons + cohorts. A new row is written
// only when the payload changed since the last snapshot (hash compare), so the
// table stays a compact change history rather than a daily full copy.
export async function runArchiveIdentity(): Promise<IdentityArchiveResult> {
if (!isConfigured()) {
return { personsScanned: 0, personSnapshots: 0, cohortsScanned: 0, cohortSnapshots: 0, error: "posthog_not_configured" };
}
let personsScanned = 0;
let personSnapshots = 0;
let cohortsScanned = 0;
let cohortSnapshots = 0;
let error: string | undefined;
// ── Persons ──
try {
const persons = await listPersons();
personsScanned = persons.length;
// Latest stored hash per person (one query via DISTINCT ON).
const latest = await prisma.$queryRaw<Array<{ personId: string; propertiesHash: string }>>`
SELECT DISTINCT ON ("personId") "personId", "propertiesHash"
FROM posthog_person_snapshots
WHERE "projectKey" = ${PROJECT_KEY}
ORDER BY "personId", "capturedAt" DESC
`;
const latestHash = new Map(latest.map((r) => [r.personId, r.propertiesHash]));
const toInsert = persons
.filter((p) => p.id)
.map((p) => ({
projectKey: PROJECT_KEY,
personId: p.id,
distinctIds: p.distinct_ids ?? [],
propertiesHash: stableHash(p.properties ?? {}),
properties: (p.properties ?? {}) as object,
}))
.filter((row) => latestHash.get(row.personId) !== row.propertiesHash);
if (toInsert.length) {
const res = await prisma.posthogPersonSnapshot.createMany({ data: toInsert as never });
personSnapshots = res.count;
}
} catch (e) {
error = `persons_failed: ${(e as Error).message}`;
}
// ── Cohorts ──
try {
const cohorts = await listCohorts();
cohortsScanned = cohorts.length;
const latest = await prisma.$queryRaw<Array<{ cohortId: number; stateHash: string }>>`
SELECT DISTINCT ON ("cohortId") "cohortId", "stateHash"
FROM posthog_cohort_snapshots
WHERE "projectKey" = ${PROJECT_KEY}
ORDER BY "cohortId", "capturedAt" DESC
`;
const latestHash = new Map(latest.map((r) => [r.cohortId, r.stateHash]));
const toInsert = cohorts.map((c) => {
const filters = c.filters ?? c.groups ?? {};
return {
projectKey: PROJECT_KEY,
cohortId: c.id,
name: c.name ?? "",
count: c.count ?? null,
isStatic: Boolean(c.is_static),
stateHash: stableHash({ count: c.count ?? null, filters }),
filters: filters as object,
};
}).filter((row) => latestHash.get(row.cohortId) !== row.stateHash);
if (toInsert.length) {
const res = await prisma.posthogCohortSnapshot.createMany({ data: toInsert as never });
cohortSnapshots = res.count;
}
} catch (e) {
error = error ?? `cohorts_failed: ${(e as Error).message}`;
}
return { personsScanned, personSnapshots, cohortsScanned, cohortSnapshots, error };
}

View File

@@ -0,0 +1,215 @@
import { gzipSync } from "node:zlib";
import { prisma } from "../db";
import {
getProjects,
isConfigured,
listEventsPage,
listIssuesPage,
type SentryEventRaw,
} from "../lib/sentry";
import { putBuffer } from "../lib/minio";
const PROJECT_KEY = process.env.INSIGHT_PROJECT_KEY ?? "sase";
const ISSUE_MAX_PAGES = Number(process.env.SENTRY_ISSUE_MAX_PAGES ?? "20");
const EVENT_MAX_PAGES = Number(process.env.SENTRY_EVENT_MAX_PAGES ?? "40");
const MAX_DUMP_DAYS = Number(process.env.SENTRY_MAX_DUMP_DAYS ?? "120");
const ARCHIVE_BUCKET = process.env.SENTRY_ARCHIVE_BUCKET ?? "sentry-archive";
export type SentryArchiveResult = {
issuesSynced: number;
eventsFetched: number;
eventsInserted: number;
eventsDuplicate: number;
dumped: number;
perProject: Record<string, { fetched: number; inserted: number; duplicate: number }>;
error?: string;
};
function toDate(v: unknown): Date | null {
if (!v) return null;
const d = new Date(String(v));
return Number.isNaN(d.getTime()) ? null : d;
}
export async function runArchiveSentry(): Promise<SentryArchiveResult> {
const perProject: SentryArchiveResult["perProject"] = {};
if (!isConfigured()) {
return {
issuesSynced: 0,
eventsFetched: 0,
eventsInserted: 0,
eventsDuplicate: 0,
dumped: 0,
perProject,
error: "sentry_not_configured",
};
}
const projects = getProjects();
let issuesSynced = 0;
let eventsFetched = 0;
let eventsInserted = 0;
let eventsDuplicate = 0;
let error: string | undefined;
// ── Issues: single org-wide loop covers all projects. ───────────────────
// Each issue carries `project.slug` so rows preserve which Sentry project
// raised them.
try {
let cursor: string | undefined;
for (let page = 0; page < ISSUE_MAX_PAGES; page++) {
const { data, nextCursor } = await listIssuesPage(cursor);
if (!data.length) break;
for (const i of data) {
if (!i.id) continue;
const sentrySourceProject = i.project?.slug ?? null;
const row = {
projectKey: PROJECT_KEY,
sentrySourceProject,
shortId: i.shortId ?? null,
title: i.title ?? null,
culprit: i.culprit ?? null,
level: i.level ?? null,
status: i.status ?? null,
type: i.type ?? null,
count: i.count != null ? Number(i.count) : null,
userCount: i.userCount ?? null,
firstSeen: toDate(i.firstSeen),
lastSeen: toDate(i.lastSeen),
permalink: i.permalink ?? null,
metadata: (i.metadata ?? {}) as object,
lastSyncedAt: new Date(),
};
await prisma.sentryIssue.upsert({
where: { id: i.id },
create: { id: i.id, ...row },
update: row,
});
issuesSynced++;
}
if (!nextCursor) break;
cursor = nextCursor;
}
} catch (e) {
error = `issues_failed: ${(e as Error).message}`;
}
// ── Events: per Sentry project, newest-first, stop once caught up. ──────
for (const slug of projects) {
const counters = { fetched: 0, inserted: 0, duplicate: 0 };
perProject[slug] = counters;
try {
let cursor: string | undefined;
for (let page = 0; page < EVENT_MAX_PAGES; page++) {
const { data, nextCursor } = await listEventsPage(slug, cursor);
if (!data.length) break;
const batch = data
.map((e: SentryEventRaw) => {
const eventId = String(e.eventID ?? e.id ?? "");
const ts = toDate(e.dateCreated ?? e.dateReceived);
if (!eventId || !ts) return null;
return {
eventId,
issueId: e.groupID ? String(e.groupID) : null,
projectKey: PROJECT_KEY,
sentrySourceProject: slug,
title: e.title ?? null,
message: typeof e.message === "string" ? e.message : null,
level: typeof e.level === "string" ? (e.level as string) : null,
platform: e.platform ?? null,
timestamp: ts,
tags: (e.tags ?? []) as object,
payload: e as object,
};
})
.filter((r): r is NonNullable<typeof r> => r !== null);
counters.fetched += batch.length;
eventsFetched += batch.length;
if (batch.length) {
const res = await prisma.sentryEvent.createMany({
data: batch as never,
skipDuplicates: true,
});
counters.inserted += res.count;
counters.duplicate += batch.length - res.count;
eventsInserted += res.count;
eventsDuplicate += batch.length - res.count;
// A full page with no new rows means we've reached already-archived events.
if (res.count === 0) break;
}
if (!nextCursor) break;
cursor = nextCursor;
}
} catch (e) {
// Project-level failure doesn't stop the other projects from being archived.
error = error ?? `events_failed[${slug}]: ${(e as Error).message}`;
}
}
// ── Cold dump closed days to MinIO ──
let dumped = 0;
try {
dumped = await dumpClosedDays(new Date());
} catch (e) {
error = error ?? `dump_failed: ${(e as Error).message}`;
}
return {
issuesSynced,
eventsFetched,
eventsInserted,
eventsDuplicate,
dumped,
perProject,
error,
};
}
async function dumpClosedDays(now: Date): Promise<number> {
const todayStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
const undumped = await prisma.sentryEvent.findMany({
where: { projectKey: PROJECT_KEY, dumpedAt: null, timestamp: { lt: todayStart } },
select: { timestamp: true },
orderBy: { timestamp: "asc" },
});
if (undumped.length === 0) return 0;
const days = Array.from(new Set(undumped.map((r) => r.timestamp.toISOString().slice(0, 10)))).slice(0, MAX_DUMP_DAYS);
let dumped = 0;
for (const day of days) {
const dayStart = new Date(`${day}T00:00:00.000Z`);
const dayEnd = new Date(dayStart.getTime() + 24 * 3600_000);
const rows = await prisma.sentryEvent.findMany({
where: { projectKey: PROJECT_KEY, timestamp: { gte: dayStart, lt: dayEnd } },
orderBy: { timestamp: "asc" },
});
if (rows.length === 0) continue;
const jsonl = rows
.map((r) =>
JSON.stringify({
eventId: r.eventId,
issueId: r.issueId,
sentrySourceProject: r.sentrySourceProject,
title: r.title,
message: r.message,
level: r.level,
platform: r.platform,
timestamp: r.timestamp.toISOString(),
tags: r.tags,
payload: r.payload,
}),
)
.join("\n");
const gz = gzipSync(Buffer.from(jsonl, "utf-8"));
const [y, m, d] = day.split("-");
await putBuffer(ARCHIVE_BUCKET, `${PROJECT_KEY}/${y}/${m}/${d}.jsonl.gz`, gz, "application/gzip");
const marked = await prisma.sentryEvent.updateMany({
where: { projectKey: PROJECT_KEY, dumpedAt: null, timestamp: { gte: dayStart, lt: dayEnd } },
data: { dumpedAt: new Date() },
});
dumped += marked.count;
}
return dumped;
}

View File

@@ -145,11 +145,17 @@ export function compressSnapshots(
const formatCustom = (ev: CanonicalEvent): string => {
// Inline a tiny subset of important properties to keep tokens bounded.
// `error`, `source`, `vin` are essential for vin_decode_failed disambiguation
// (client-side validation reject vs. upstream provider failure) — without
// them the LLM cannot tell whether a failure actually reached a provider.
const p = ev.properties ?? {};
const keys = [
"provider",
"provider_attempted",
"error_code",
"error",
"source",
"vin",
"result",
"plan",
"amount",
@@ -391,11 +397,39 @@ export function compressSnapshots(
hypotheses.push("Fresh trial activated and decoded a VIN successfully — onboarding succeeded");
}
// Pattern 3: VIN decode failure or provider fallback — investigate upstream.
const vinFailures = count("vin_decode_failed") + count("vin_decode_error");
if (vinFailures >= 2 || count("provider_fallback_triggered") >= 1) {
// Pattern 3: VIN decode failure or provider fallback — but separate the
// client-side validation rejects (input never reached a provider) from real
// upstream failures. Lumping them together biases the LLM toward "provider
// issue" verdicts when the actual signal is user input affordance.
const vinFailEvents = customEvents.filter(
(c) => c.name === "vin_decode_failed" || c.name === "vin_decode_error",
);
const clientRejects = vinFailEvents.filter((c) => {
const p = (c.properties ?? {}) as Record<string, unknown>;
const err = String(p.error ?? "");
const vin = String(p.vin ?? "");
if (/(Geçersiz şase|17 karakter|I, O, Q|invalid VIN|must be 17|format)/i.test(err)) return true;
if (vin && vin.replace(/\s/g, "").length !== 17) return true;
if (vin && /[IOQ]/i.test(vin)) return true;
return false;
});
const realProviderFails = vinFailEvents.length - clientRejects.length;
const fallbacks = count("provider_fallback_triggered");
if (clientRejects.length >= 1 && realProviderFails === 0) {
const sample = String(
((clientRejects[0].properties ?? {}) as Record<string, unknown>).vin ?? "",
).slice(0, 24);
hypotheses.push(
`VIN decode failure pattern (failures=${vinFailures}, fallbacks=${count("provider_fallback_triggered")}) — check provider health`,
`VIN inputuna geçersiz format girildi (${clientRejects.length}x client-side validation reddi${sample ? `, örn. "${sample}"` : ""}) — provider çağrılmadı, input affordance / yanlış alan kullanımı problemi`,
);
} else if (realProviderFails >= 2 || fallbacks >= 1) {
hypotheses.push(
`VIN decode failure pattern (provider failures=${realProviderFails}, fallbacks=${fallbacks}) — check provider health`,
);
} else if (realProviderFails === 1) {
hypotheses.push(
`Single VIN decode failure reached a provider — likely transient, watch for repeat`,
);
}

View File

@@ -0,0 +1,116 @@
// Content-generation budget guard. Mirrors lib/budget.ts but keeps a separate
// envelope from the insight pipeline: spend is summed only over cost_ledger
// rows whose promptTag starts with "content_", and the caps come from the
// `content_*` budget settings. This way content generation can never exhaust
// the insight analysis budget (or vice-versa).
import { prisma } from "../db";
import type { BudgetState } from "./budget";
export type ContentBudgetDecision = {
allow: boolean;
state: BudgetState;
reason: string;
forceTier?: "flash";
todayUsd: number;
monthUsd: number;
limits: {
monthlyHardCap: number;
dailySoftCap: number;
dailyHardCap: number;
perCallMax: number;
};
};
async function getNumber(key: string, fallback: number): Promise<number> {
const row = await prisma.budgetSetting.findFirst({
where: { projectKey: null, settingKey: key },
});
const v = row?.settingValue;
return typeof v === "number" ? v : fallback;
}
async function getBool(key: string, fallback: boolean): Promise<boolean> {
const row = await prisma.budgetSetting.findFirst({
where: { projectKey: null, settingKey: key },
});
const v = row?.settingValue;
return typeof v === "boolean" ? v : fallback;
}
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));
}
// Sums only content_* spend.
async function contentSpend(since: Date): Promise<number> {
const agg = await prisma.costLedger.aggregate({
where: { createdAt: { gte: since }, promptTag: { startsWith: "content_" } },
_sum: { costTotalUsd: true },
});
return Number(agg._sum.costTotalUsd ?? 0);
}
export async function checkContentBudget(): Promise<ContentBudgetDecision> {
const monthlyHardCap = await getNumber("content_monthly_hard_cap_usd", 15);
const dailySoftCap = await getNumber("content_daily_soft_cap_usd", 1);
const dailyHardCap = await getNumber("content_daily_hard_cap_usd", 2);
const perCallMax = await getNumber("content_per_call_max_usd", 0.3);
const paused = await getBool("content_paused", false);
const limits = { monthlyHardCap, dailySoftCap, dailyHardCap, perCallMax };
if (paused) {
return {
allow: false,
state: "hard_paused",
reason: "content_paused setting is true",
todayUsd: 0,
monthUsd: 0,
limits,
};
}
const now = new Date();
const [todayUsd, monthUsd] = await Promise.all([
contentSpend(startOfDayUtc(now)),
contentSpend(startOfMonthUtc(now)),
]);
if (monthUsd >= monthlyHardCap) {
return {
allow: false,
state: "monthly_paused",
reason: `content month spend $${monthUsd.toFixed(4)} >= monthly cap $${monthlyHardCap}`,
todayUsd,
monthUsd,
limits,
};
}
if (todayUsd >= dailyHardCap) {
return {
allow: false,
state: "hard_paused",
reason: `content today spend $${todayUsd.toFixed(4)} >= daily hard cap $${dailyHardCap}`,
todayUsd,
monthUsd,
limits,
};
}
if (todayUsd >= dailySoftCap) {
return {
allow: true,
state: "soft_throttled",
reason: `content today spend $${todayUsd.toFixed(4)} >= daily soft cap $${dailySoftCap}, force flash`,
forceTier: "flash",
todayUsd,
monthUsd,
limits,
};
}
return { allow: true, state: "active", reason: "ok", todayUsd, monthUsd, limits };
}

View File

@@ -0,0 +1,216 @@
// Content-generation prompt templates (Phase 8a). Same shape & DB table as
// the insight prompts (PromptTemplate) — distinguished by the `content_*` tag
// prefix, which is also how content spend is separated in the cost ledger.
//
// One topic-idea prompt (auto topic generation) + one prompt per channel.
// All natural-language output is Turkish (B2B automotive marketing tone).
import type { PromptTemplate } from "./prompts";
export const CONTENT_TAGS = [
"content_topic_ideas",
"content_blog",
"content_linkedin",
"content_x",
"content_instagram",
] as const;
export type ContentChannel = "blog" | "linkedin" | "x" | "instagram";
// Maps a channel to its generation prompt tag.
export function channelPromptTag(channel: ContentChannel): string {
return `content_${channel}`;
}
const SASE_BRAND = `Sase.tr hakkında:
- B2B SaaS: VIN/şasi sorgulama + OEM yedek parça uyumluluğu. Hedef kitle: Türkiye'deki yedek parçacılar, oto servisleri, tamirhaneler, parça ithalatçıları.
- Değer önerisi: doğru parçayı VIN'den hızlı bul, yanlış parça iadesini azalt, 4 upstream katalog (PL24/Partslink24, PCAT, RMEX, TecDoc) tek arayüzde.
- Abonelik: starter / brand_specific / full. Deneme akışı var.
Marka tonu:
- Profesyonel, net, sektörün dilini bilen. Esnaf/teknisyen okuyucuya saygılı, abartısız.
- Otomotiv terimlerini doğru kullan: VIN, şasi no, OEM, OE/eşdeğer parça, OBD, motor kodu, donanım kodu, katalog, çapraz referans.
- Satış baskısı değil; gerçek bir sorunu çözerek güven kur. CTA yumuşak ama net (örn. "Sase.tr'de VIN ile parça aramayı ücretsiz deneyin").
- Yanlış/uydurma teknik iddia YOK. Emin değilsen genel konuş, spesifik sayı/iddia uydurma.
Çıktı kuralları:
- SADECE şemaya uyan geçerli JSON döndür. Markdown yok, kod bloğu yok, açıklama yok.
- Tüm doğal dil alanları (başlık, gövde, caption, brief, CTA, vb.) TÜRKÇE. Hashtag'ler Türkçe veya sektör-standart İngilizce olabilir (örn. #yedekparça #OEM).
- Alan uzunluk sınırlarına (maxLength) uy; aşma, gerekirse kısalt.`;
// ---- Topic idea generation ----
const TOPIC_IDEAS_SCHEMA = {
type: "object",
required: ["topics"],
properties: {
topics: {
type: "array",
minItems: 1,
maxItems: 8,
items: {
type: "object",
required: ["title", "brief", "channels", "keywords"],
properties: {
title: { type: "string", maxLength: 160 },
brief: { type: "string", maxLength: 600 },
angle: { type: "string", maxLength: 300 },
channels: {
type: "array",
minItems: 1,
maxItems: 4,
items: { enum: ["blog", "linkedin", "x", "instagram"] },
},
keywords: { type: "array", minItems: 1, maxItems: 12, items: { type: "string" } },
},
},
},
},
};
// ---- Per-channel content schemas ----
const BLOG_SCHEMA = {
type: "object",
required: ["title", "slug", "meta_description", "body_markdown", "tags"],
properties: {
title: { type: "string", maxLength: 160 },
slug: { type: "string", maxLength: 120 },
meta_description: { type: "string", maxLength: 300 },
body_markdown: { type: "string", maxLength: 14000 },
tags: { type: "array", minItems: 1, maxItems: 12, items: { type: "string" } },
cta: { type: "string", maxLength: 300 },
},
};
const LINKEDIN_SCHEMA = {
type: "object",
required: ["body", "hashtags"],
properties: {
body: { type: "string", maxLength: 2600 },
hashtags: { type: "array", minItems: 0, maxItems: 10, items: { type: "string" } },
cta: { type: "string", maxLength: 200 },
},
};
const X_SCHEMA = {
type: "object",
required: ["tweets"],
properties: {
tweets: { type: "array", minItems: 1, maxItems: 8, items: { type: "string", maxLength: 280 } },
hashtags: { type: "array", minItems: 0, maxItems: 6, items: { type: "string" } },
},
};
const INSTAGRAM_SCHEMA = {
type: "object",
required: ["caption", "hashtags"],
properties: {
caption: { type: "string", maxLength: 2200 },
hashtags: { type: "array", minItems: 0, maxItems: 30, items: { type: "string" } },
image_prompt: { type: "string", maxLength: 400 },
},
};
export const CONTENT_SEED_PROMPTS: PromptTemplate[] = [
{
tag: "content_topic_ideas",
version: 1,
name: "Content Topic Ideas v1 (TR)",
systemPrompt: `Sase.tr için içerik konusu fikirleri üreten bir B2B içerik stratejistisin. VIN/OEM/yedek parça/oto servis temalarında, hedef kitlenin (yedek parçacılar, servisler) gerçekten arayacağı veya faydalanacağı, SEO ve sosyal için uygun konular öner. Tekrara düşme, jenerik olma; sektöre özgü ve eyleme dönüştürülebilir açılar bul.
${SASE_BRAND}
Şema (tam olarak buna uy):
${JSON.stringify(TOPIC_IDEAS_SCHEMA)}`,
userPromptTemplate: `{{context}}
{{count}} adet yeni içerik konusu fikri üret. Her fikir için: başlık, kısa brief (içeriğin ne anlatacağı), açı (angle), uygun kanallar ve anahtar kelimeler. Yukarıdaki "son konular" listesindekilere benzer/çakışan konu ÜRETME. Şemaya uygun JSON döndür.`,
outputSchemaJson: TOPIC_IDEAS_SCHEMA,
modelTier: "pro",
maxOutputTokens: 1800,
temperature: 0.7,
},
{
tag: "content_blog",
version: 1,
name: "Content Blog v1 (TR, SEO)",
systemPrompt: `Sase.tr için Türkçe, SEO-optimize blog yazıları yazan bir içerik editörüsün. Yazı yapısı net (giriş, alt başlıklar, sonuç), okunabilir, gerçek değer veren ve anahtar kelimeleri doğal kullanan olmalı. Markdown gövdesinde başlıklar (##), kısa paragraflar ve gerektiğinde liste kullan.
${SASE_BRAND}
Şema:
${JSON.stringify(BLOG_SCHEMA)}`,
userPromptTemplate: `Konu: {{title}}
Brief: {{brief}}
ı: {{angle}}
Anahtar kelimeler: {{keywords}}
Bu konuda Türkçe bir blog yazısı üret. slug kısa ve URL-uyumlu (küçük harf, tireli, Türkçe karakter yok). meta_description SEO için 150-160 karakter civarı. Şemaya uygun JSON döndür.`,
outputSchemaJson: BLOG_SCHEMA,
modelTier: "pro",
maxOutputTokens: 4000,
temperature: 0.4,
},
{
tag: "content_linkedin",
version: 1,
name: "Content LinkedIn v1 (TR, B2B)",
systemPrompt: `Sase.tr için LinkedIn şirket sayfası gönderileri yazan bir B2B sosyal medya editörüsün. Ton profesyonel ama insani; ilk satır dikkat çeken bir kanca olmalı. Kısa paragraflar, gerektiğinde satır araları. Aşırı hashtag kullanma.
${SASE_BRAND}
Şema:
${JSON.stringify(LINKEDIN_SCHEMA)}`,
userPromptTemplate: `Konu: {{title}}
Brief: {{brief}}
ı: {{angle}}
Anahtar kelimeler: {{keywords}}
Bu konuda bir LinkedIn gönderisi üret. Şemaya uygun JSON döndür.`,
outputSchemaJson: LINKEDIN_SCHEMA,
modelTier: "flash",
maxOutputTokens: 1200,
temperature: 0.5,
},
{
tag: "content_x",
version: 1,
name: "Content X/Twitter v1 (TR)",
systemPrompt: `Sase.tr için X (Twitter) gönderileri/thread'leri yazan bir sosyal medya editörüsün. Her tweet ≤280 karakter. Tek güçlü gönderi ya da kısa bir thread üret; ilk tweet kanca olmalı, son tweet yumuşak CTA içerebilir.
${SASE_BRAND}
Şema:
${JSON.stringify(X_SCHEMA)}`,
userPromptTemplate: `Konu: {{title}}
Brief: {{brief}}
ı: {{angle}}
Anahtar kelimeler: {{keywords}}
Bu konuda bir X gönderisi ya da kısa thread üret (en fazla 8 tweet). Her tweet ayrı bir dizi elemanı, her biri ≤280 karakter. Şemaya uygun JSON döndür.`,
outputSchemaJson: X_SCHEMA,
modelTier: "flash",
maxOutputTokens: 1000,
temperature: 0.6,
},
{
tag: "content_instagram",
version: 1,
name: "Content Instagram v1 (TR)",
systemPrompt: `Sase.tr için Instagram caption'ları yazan bir sosyal medya editörüsün. Caption ilgi çekici, kısa paragraflı, emoji'yi ölçülü kullanan olsun. Hashtag'leri caption sonunda topla. Ayrıca içeriğe uygun bir görsel üretim prompt'u (image_prompt) öner (İngilizce, kısa, görsel betimleme).
${SASE_BRAND}
Şema:
${JSON.stringify(INSTAGRAM_SCHEMA)}`,
userPromptTemplate: `Konu: {{title}}
Brief: {{brief}}
ı: {{angle}}
Anahtar kelimeler: {{keywords}}
Bu konuda bir Instagram caption'ı + hashtag seti + image_prompt üret. Şemaya uygun JSON döndür.`,
outputSchemaJson: INSTAGRAM_SCHEMA,
modelTier: "flash",
maxOutputTokens: 1000,
temperature: 0.6,
},
];

View File

@@ -14,3 +14,17 @@ export function fingerprintHash(parts: Array<string | number | null | undefined>
.join("|");
return createHash("sha256").update(norm).digest("hex").slice(0, 24);
}
// Deterministic hash of an arbitrary JSON value (object keys sorted) — used for
// change detection so re-serialized-but-equal payloads don't produce false diffs.
function stableStringify(v: unknown): string {
if (v === null || typeof v !== "object") return JSON.stringify(v) ?? "null";
if (Array.isArray(v)) return `[${v.map(stableStringify).join(",")}]`;
const obj = v as Record<string, unknown>;
const keys = Object.keys(obj).sort();
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(",")}}`;
}
export function stableHash(value: unknown): string {
return createHash("sha256").update(stableStringify(value)).digest("hex").slice(0, 32);
}

View File

@@ -41,6 +41,18 @@ export async function putText(
await c.putObject(bucket, key, buf, buf.length, { "Content-Type": contentType });
}
export async function putBuffer(
bucket: string,
key: string,
buf: Buffer,
contentType = "application/octet-stream",
): Promise<void> {
const c = getMinio();
if (!c) throw new Error("minio_not_configured");
await ensureBucket(bucket);
await c.putObject(bucket, key, buf, buf.length, { "Content-Type": contentType });
}
export async function getText(bucket: string, key: string): Promise<string> {
const c = getMinio();
if (!c) throw new Error("minio_not_configured");

View File

@@ -99,6 +99,24 @@ export function isConfigured(): boolean {
return Boolean(TOKEN && PROJECT_ID);
}
// ---------- HogQL query API ----------
export type HogQLResponse = { columns: string[]; results: unknown[][] };
// Runs a HogQL query against the project and returns columns + row arrays.
// Used by the archival pipeline to page the full event stream.
export async function hogqlQuery(query: string): Promise<HogQLResponse> {
const url = `${HOST}/api/projects/${PROJECT_ID}/query/`;
const res = await fetch(url, {
method: "POST",
headers: { ...headers(), "Content-Type": "application/json" },
body: JSON.stringify({ query: { kind: "HogQLQuery", query } }),
});
if (!res.ok) throw new Error(`posthog hogql ${res.status}: ${await res.text().catch(() => "")}`);
const data = (await res.json()) as { columns?: string[]; results?: unknown[][] };
return { columns: data.columns ?? [], results: data.results ?? [] };
}
// ---------- Custom events ----------
export type PHCustomEvent = {
@@ -159,6 +177,55 @@ export async function getPerson(distinctId: string): Promise<PHPerson | null> {
return data.results?.[0] ?? null;
}
// ---------- Person & cohort listing (archival snapshots) ----------
export type PHPersonFull = {
id: string; // PostHog person uuid (== events.person_id)
name?: string;
distinct_ids: string[];
properties: Record<string, unknown>;
};
// Lists all persons via the REST endpoint, following `next` pagination.
export async function listPersons(opts?: { limit?: number; maxPages?: number }): Promise<PHPersonFull[]> {
const limit = opts?.limit ?? 100;
const maxPages = opts?.maxPages ?? 50;
const out: PHPersonFull[] = [];
let next: string | null = `${HOST}/api/projects/${PROJECT_ID}/persons/?limit=${limit}`;
for (let page = 0; page < maxPages && next; page++) {
const res = await fetch(next, { headers: headers() });
if (!res.ok) throw new Error(`posthog persons ${res.status}`);
const data = (await res.json()) as { results?: PHPersonFull[]; next?: string | null };
out.push(...(data.results ?? []));
next = data.next ?? null;
}
return out;
}
export type PHCohort = {
id: number;
name: string;
count?: number | null;
is_static?: boolean;
filters?: unknown;
groups?: unknown;
};
// Lists cohort definitions (with membership counts), following `next` pagination.
export async function listCohorts(opts?: { maxPages?: number }): Promise<PHCohort[]> {
const maxPages = opts?.maxPages ?? 20;
const out: PHCohort[] = [];
let next: string | null = `${HOST}/api/projects/${PROJECT_ID}/cohorts/`;
for (let page = 0; page < maxPages && next; page++) {
const res = await fetch(next, { headers: headers() });
if (!res.ok) throw new Error(`posthog cohorts ${res.status}`);
const data = (await res.json()) as { results?: PHCohort[]; next?: string | null };
out.push(...(data.results ?? []));
next = data.next ?? null;
}
return out;
}
export async function getGroup(
groupType: string,
groupKey: string,

View File

@@ -264,17 +264,25 @@ Return JSON per the schema. Use occurrence patterns from the bundle summary to e
},
{
tag: "provider_quality",
version: 3,
name: "Provider Quality v3 (TR, ext maxLen)",
version: 4,
name: "Provider Quality v4 (TR, client-validation guardrail)",
systemPrompt: `You analyze upstream provider failures (PL24/PCAT/RMEX/TecDoc) impacting Sase.tr users. Identify which provider failed and propose action.
${SASE_CONTEXT}
CRITICAL GUARDRAIL — distinguish client-side input rejection from upstream provider failure:
- A \`vin_decode_failed\` event is ONLY a provider issue when the input actually reached a provider. Check the inlined event properties:
- If \`error\` contains "Geçersiz şase numarası", "17 karakter olmalı", "I, O, Q", "invalid VIN", or any 17-character / format complaint → this was a **client-side validation reject**, the server was NEVER called.
- If \`vin\` is shorter than 17 characters, contains spaces (e.g. "5Q0 907 521" — that's a VW PART NUMBER, not a VIN), or contains I/O/Q → same: client-side rejection.
- If \`source\` is a UI location ("landing", "dashboard") and no \`provider_attempted\` field is present → did not reach a provider.
- In all of the above cases this is **NOT a provider_quality issue**. Return \`confidence: 0.15\`, \`failure_mode: "unknown"\`, \`affected_provider: "multi"\`, and in the \`hypothesis\` explicitly state: "Bu provider hatası değil — kullanıcı VIN alanına geçersiz format girdi (client-side reddi). Doğru kategori ux_friction / input affordance." This low-confidence output is preferable to inventing a provider issue.
- Only return a high-confidence provider_quality verdict when at least 2 events show \`provider_attempted\` set OR the timeline shows network 5xx/timeout patterns from upstream endpoints (e.g. \`/api/vin/decode\`, \`/api/parts\`, requests to PL24/PCAT/RMEX/TecDoc paths).
Schema:
${JSON.stringify(PROVIDER_SCHEMA)}`,
userPromptTemplate: `{{timeline}}
Return JSON per the schema.`,
Return JSON per the schema. Before classifying as provider issue, verify the guardrail above by checking inlined event properties (error, source, vin).`,
outputSchemaJson: PROVIDER_SCHEMA,
modelTier: "flash",
maxOutputTokens: 800,
@@ -299,6 +307,12 @@ export function pickPromptTag(tags: string[]): string {
// Upgrade hesitation — pricing page concerns
if (set.has("upgrade_hesitation")) return "upgrade_hesitation";
// Client-side VIN validation rejects must route to ux_friction — these are
// input affordance problems (user typed a part number / short string into the
// VIN field), not upstream provider failures. Checked *before* provider tags
// so that the more specific signal wins.
if (set.has("vin_decode_client_validation_fail")) return "ux_friction";
// Provider issues
if (
set.has("provider_reliability_issue") ||

View File

@@ -1,5 +1,6 @@
import { prisma } from "../db";
import { SEED_PROMPTS } from "./prompts";
import { CONTENT_SEED_PROMPTS } from "./content-prompts";
const DEFAULT_BUDGETS: Array<{ key: string; value: unknown }> = [
{ key: "monthly_hard_cap_usd", value: 30 },
@@ -9,12 +10,20 @@ const DEFAULT_BUDGETS: Array<{ key: string; value: unknown }> = [
{ key: "min_score_for_analysis", value: 30 },
{ key: "cache_ttl_hours", value: 6 },
{ key: "analysis_paused", value: false },
// Content generation — separate envelope from insight analysis.
{ key: "content_monthly_hard_cap_usd", value: 15 },
{ key: "content_daily_soft_cap_usd", value: 1 },
{ key: "content_daily_hard_cap_usd", value: 2 },
{ key: "content_per_call_max_usd", value: 0.3 },
// Safe default: first deploy lands paused so output quality can be reviewed
// before the cron auto-spends. Flip off in budget settings to enable.
{ key: "content_paused", value: true },
];
export async function upsertSeedData(): Promise<void> {
// Prompt templates — insert new versions if (tag, version) doesn't exist.
// When inserting a new version, deactivate older active versions of the same tag.
for (const p of SEED_PROMPTS) {
for (const p of [...SEED_PROMPTS, ...CONTENT_SEED_PROMPTS]) {
const existing = await prisma.promptTemplate.findUnique({
where: { tag_version: { tag: p.tag, version: p.version } },
});

View File

@@ -0,0 +1,112 @@
// Sentry API client (read-only) for the archival pipeline. Uses a User Auth
// Token with org:read / project:read / event:read. EU-region orgs use the
// de.sentry.io API base.
//
// Multi-project support: SENTRY_PROJECT is a CSV (e.g. "python,sase-web").
// Issues come from the org-wide endpoint (one call covers all projects), so
// they're fetched in a single loop and the per-issue `project.slug` is
// retained. Events are project-scoped on Sentry's side, so the archive job
// iterates `getProjects()` and calls `listEventsPage(slug, cursor)` per slug.
const TOKEN = process.env.SENTRY_AUTH_TOKEN ?? "";
const ORG = process.env.SENTRY_ORG ?? "";
const RAW_PROJECTS = process.env.SENTRY_PROJECT ?? "";
const API_BASE = (process.env.SENTRY_API_BASE ?? "https://sentry.io/api/0").replace(/\/$/, "");
const PROJECTS: string[] = RAW_PROJECTS.split(",")
.map((s) => s.trim())
.filter(Boolean);
export function isConfigured(): boolean {
return Boolean(TOKEN && ORG && PROJECTS.length > 0);
}
export function getProjects(): string[] {
return [...PROJECTS];
}
const headers = () => ({ Authorization: `Bearer ${TOKEN}`, Accept: "application/json" });
// Sentry paginates via a Link header: <url>; rel="next"; results="true"; cursor="...".
// Returns the next cursor only when results="true".
function parseNextCursor(link: string | null): string | null {
if (!link) return null;
for (const part of link.split(",")) {
if (/rel="next"/.test(part) && /results="true"/.test(part)) {
const m = part.match(/cursor="([^"]+)"/);
if (m) return m[1];
}
}
return null;
}
type Page<T> = { data: T[]; nextCursor: string | null };
async function getPage<T>(path: string, params: Record<string, string>): Promise<Page<T>> {
const url = new URL(`${API_BASE}${path}`);
for (const [k, v] of Object.entries(params)) if (v) url.searchParams.set(k, v);
const res = await fetch(url, { headers: headers() });
if (!res.ok) throw new Error(`sentry ${path} ${res.status}: ${await res.text().catch(() => "")}`);
const data = (await res.json()) as T[];
return { data, nextCursor: parseNextCursor(res.headers.get("link")) };
}
export type SentryProjectRef = {
id?: string;
slug?: string;
name?: string;
};
export type SentryIssueRaw = {
id: string;
shortId?: string;
title?: string;
culprit?: string;
level?: string;
status?: string;
type?: string;
count?: string | number;
userCount?: number;
firstSeen?: string;
lastSeen?: string;
permalink?: string;
metadata?: Record<string, unknown>;
project?: SentryProjectRef;
};
export type SentryEventRaw = {
id?: string;
eventID?: string;
groupID?: string;
title?: string;
message?: string;
"event.type"?: string;
platform?: string;
dateCreated?: string;
dateReceived?: string;
tags?: unknown;
[k: string]: unknown;
};
// One page of org issues (newest activity first). statsPeriod bounds the window.
// Sentry's response includes a `project: { slug, id, name }` per issue so the
// caller can distinguish which Sentry project a row came from without a
// second round-trip.
export function listIssuesPage(cursor?: string, statsPeriod = "90d"): Promise<Page<SentryIssueRaw>> {
return getPage<SentryIssueRaw>(`/organizations/${ORG}/issues/`, {
project: "",
query: "",
statsPeriod,
limit: "100",
...(cursor ? { cursor } : {}),
});
}
// One page of project events, full payloads, newest first.
export function listEventsPage(projectSlug: string, cursor?: string): Promise<Page<SentryEventRaw>> {
return getPage<SentryEventRaw>(`/projects/${ORG}/${projectSlug}/events/`, {
full: "true",
...(cursor ? { cursor } : {}),
});
}
export const sentryConfig = { ORG, PROJECTS, API_BASE };

View File

@@ -71,23 +71,59 @@ export function tagSession(s: SessionMeta, ctx: EnrichmentCtx | null): TagResult
}
// ─── VIN decode failure pattern ───
// Distinguish *client-side validation rejects* (input too short / wrong format
// / forbidden chars I,O,Q — server never called) from *upstream provider
// failures* (PL24/PCAT/RMEX/TecDoc timeout/error). Bundling them together
// produces false-positive "provider issue" insights (e.g. user typing a VW
// part number "5Q0 907 521" into the VIN field hits client-side regex; no
// provider was contacted, so it isn't a provider quality signal).
const vinFails = events.filter((e) => e.name === "vin_decode_failed");
if (vinFails.length >= 2) {
const providers = new Set(
vinFails.map((e) => String(e.properties.provider_attempted ?? e.properties.source ?? "")),
);
if (providers.size === 1 && [...providers][0]) {
tags.push("vin_decode_fail_pattern");
severity = bump(severity, "P1");
} else {
// Different providers failing → still notable
tags.push("vin_decode_repeated_failure");
severity = bump(severity, "P2");
if (vinFails.length >= 1) {
const isClientValidationReject = (e: CanonicalEvent): boolean => {
const p = e.properties ?? {};
const err = String(p.error ?? "");
const vin = String(p.vin ?? "");
// Frontend Zod/regex messages we ship — keep in sync with web VIN validator.
const clientMsgRe = /(Geçersiz şase|17 karakter|I, O, Q|invalid VIN|must be 17|format)/i;
if (clientMsgRe.test(err)) return true;
// Length / forbidden-char heuristic — if the user typed something that
// couldn't possibly reach the upstream, treat as client-side rejection.
if (vin && vin.replace(/\s/g, "").length !== 17) return true;
if (vin && /[IOQ]/i.test(vin)) return true;
return false;
};
const clientRejects = vinFails.filter(isClientValidationReject);
const realFailures = vinFails.filter((e) => !isClientValidationReject(e));
if (clientRejects.length >= 1 && realFailures.length === 0) {
// Pure client-side input affordance problem — route through ux_friction,
// not provider_quality. Severity is low (no service impact).
tags.push("vin_decode_client_validation_fail");
severity = bump(severity, "P3");
} else if (realFailures.length >= 2) {
// True upstream failures: group by *provider* (not the UI source field —
// "landing"/"dashboard" are page locations, not providers).
const providers = new Set(
realFailures
.map((e) => String(e.properties.provider_attempted ?? ""))
.filter((v) => v.length > 0),
);
if (providers.size === 1) {
tags.push("vin_decode_fail_pattern");
severity = bump(severity, "P1");
} else if (providers.size > 1) {
tags.push("vin_decode_repeated_failure");
severity = bump(severity, "P2");
} else {
// Unknown provider attribution but server-side failure shape — still
// worth surfacing but as a softer signal.
tags.push("vin_decode_repeated_failure");
severity = bump(severity, "P2");
}
} else if (realFailures.length === 1) {
tags.push("vin_decode_failed_single");
severity = bump(severity, "P3");
}
} else if (vinFails.length === 1) {
// Single failure is still a quality signal, less severe
tags.push("vin_decode_failed_single");
severity = bump(severity, "P3");
}
if (has(events, "provider_fallback_triggered")) {

View File

@@ -0,0 +1,59 @@
// Content pipeline scheduler (Phase 8a). Separate BullMQ queue from the
// insight pipeline so the two domains don't share concurrency or job names.
// Cadence is conservative and every job is gated by the content budget; the
// `content_paused` budget setting is the global kill-switch.
import { Queue, Worker, type Job } from "bullmq";
import { redis } from "../redis";
import { runContentTopics } from "../jobs/content-topics";
import { runContentGenerate } from "../jobs/content-generate";
const QUEUE = "content-pipeline";
const queue = new Queue(QUEUE, { connection: redis });
async function runJob(job: Job) {
switch (job.name) {
case "content-topics": {
const res = await runContentTopics();
if (res.inserted > 0 || res.reason) {
console.log(
`[content] topics generated=${res.generated} inserted=${res.inserted} skipped=${res.skipped} cost=$${res.costUsd.toFixed(4)} budget=${res.budgetState}${res.reason ? ` reason=${res.reason}` : ""}`,
);
}
return res;
}
case "content-generate": {
const res = await runContentGenerate();
if (res.draftsCreated > 0 || res.failed > 0 || res.reason) {
console.log(
`[content] generate topics=${res.topics} drafts=${res.draftsCreated} failed=${res.failed} cost=$${res.costUsd.toFixed(4)} budget=${res.budgetState}${res.reason ? ` reason=${res.reason}` : ""}`,
);
}
return res;
}
default:
return { ok: false, error: `unknown job ${job.name}` };
}
}
export async function startContentPipeline() {
await queue.upsertJobScheduler(
"content-topics",
{ pattern: "0 */8 * * *" }, // every 8h: top up the topic backlog
{ name: "content-topics", data: {}, opts: { removeOnComplete: 30, removeOnFail: 15 } },
);
await queue.upsertJobScheduler(
"content-generate",
{ pattern: "*/10 * * * *" }, // every 10min: drain queued topics into drafts
{ name: "content-generate", data: {}, opts: { removeOnComplete: 50, removeOnFail: 25 } },
);
new Worker(QUEUE, runJob, {
connection: redis,
concurrency: 1,
lockDuration: 5 * 60_000,
stalledInterval: 60_000,
});
console.log("[content] armed: content-topics@*/8h, content-generate@*/10min");
}

View File

@@ -10,6 +10,10 @@ import { runRetention } from "../jobs/retention";
import { runEvalSet } from "../jobs/eval-run";
import { runDailyBrief } from "../jobs/daily-brief";
import { runVinAnomalyDetect } from "../jobs/vin-anomaly";
import { runArchivePosthogEvents } from "../jobs/posthog-event-archive";
import { runArchiveRecordings } from "../jobs/archive-recordings";
import { runArchiveIdentity } from "../jobs/posthog-identity-archive";
import { runArchiveSentry } from "../jobs/sentry-archive";
const QUEUE = "insight-pipeline";
@@ -87,6 +91,45 @@ async function runJob(job: Job) {
}
return res;
}
case "posthog-event-archive": {
const res = await runArchivePosthogEvents();
if (res.fetched > 0 || res.dumped > 0 || res.error) {
console.log(
`[pipeline] event-archive fetched=${res.fetched} inserted=${res.inserted} dup=${res.duplicates} pages=${res.pages} dumped=${res.dumped}${res.error ? ` error=${res.error}` : ""}`,
);
}
return res;
}
case "archive-recordings": {
const res = await runArchiveRecordings();
if (res.scanned > 0) {
console.log(
`[pipeline] archive-recordings scanned=${res.scanned} archived=${res.archived} skipped=${res.skipped} failed=${res.failed}${res.rateLimited ? " rate_limited" : ""}`,
);
}
return res;
}
case "posthog-identity-archive": {
const res = await runArchiveIdentity();
if (res.personSnapshots > 0 || res.cohortSnapshots > 0 || res.error) {
console.log(
`[pipeline] identity-archive persons=${res.personsScanned}/${res.personSnapshots} cohorts=${res.cohortsScanned}/${res.cohortSnapshots}${res.error ? ` error=${res.error}` : ""}`,
);
}
return res;
}
case "sentry-archive": {
const res = await runArchiveSentry();
if (res.eventsInserted > 0 || res.issuesSynced > 0 || res.dumped > 0 || res.error) {
const perProj = Object.entries(res.perProject)
.map(([slug, c]) => `${slug}=${c.fetched}/${c.inserted}`)
.join(" ");
console.log(
`[pipeline] sentry-archive issues=${res.issuesSynced} events=${res.eventsFetched}/${res.eventsInserted} dup=${res.eventsDuplicate} dumped=${res.dumped}${perProj ? ` [${perProj}]` : ""}${res.error ? ` error=${res.error}` : ""}`,
);
}
return res;
}
default:
return { ok: false, error: `unknown job ${job.name}` };
}
@@ -138,6 +181,26 @@ export async function startInsightPipeline() {
{ pattern: "*/5 * * * *" },
{ name: "vin-anomaly-detect", data: {}, opts: { removeOnComplete: 30, removeOnFail: 15 } },
);
await queue.upsertJobScheduler(
"posthog-event-archive",
{ pattern: "*/15 * * * *" },
{ name: "posthog-event-archive", data: {}, opts: { removeOnComplete: 50, removeOnFail: 25 } },
);
await queue.upsertJobScheduler(
"archive-recordings",
{ pattern: "0 */6 * * *" },
{ name: "archive-recordings", data: {}, opts: { removeOnComplete: 30, removeOnFail: 15 } },
);
await queue.upsertJobScheduler(
"posthog-identity-archive",
{ pattern: "0 3 * * *" },
{ name: "posthog-identity-archive", data: {}, opts: { removeOnComplete: 30, removeOnFail: 15 } },
);
await queue.upsertJobScheduler(
"sentry-archive",
{ pattern: "0 * * * *" },
{ name: "sentry-archive", data: {}, opts: { removeOnComplete: 30, removeOnFail: 15 } },
);
new Worker(QUEUE, runJob, {
connection: redis,
@@ -146,6 +209,6 @@ export async function startInsightPipeline() {
stalledInterval: 60_000,
});
console.log(
"[pipeline] armed: posthog-ingest@*/5min, tag-sessions@*/2min, compress-sessions@*/3min, analyze@*/4min, validation@05:00, github-sync@*/10min, retention@04:15, daily-brief@05:00, vin-anomaly-detect@*/5min",
"[pipeline] armed: posthog-ingest@*/5min, tag-sessions@*/2min, compress-sessions@*/3min, analyze@*/4min, validation@05:00, github-sync@*/10min, retention@04:15, daily-brief@05:00, vin-anomaly-detect@*/5min, posthog-event-archive@*/15min, archive-recordings@*/6h, posthog-identity-archive@03:00, sentry-archive@hourly",
);
}

113
docs/n8n/README.md Normal file
View File

@@ -0,0 +1,113 @@
# Content publish — n8n `publish-content` workflow (Phase 8c)
The panel generates + reviews drafts; **n8n publishes them**. The panel POSTs an
approved draft to a single n8n webhook and waits for a synchronous response.
n8n routes by `channel` (LinkedIn = native node, blog = HTTP to sase.tr API).
Synchronous by design: `sp.semih.ai` is Tailscale-only, so we avoid an
n8n→panel callback. n8n returns the result via a **Respond to Webhook** node.
## Contract
**Panel → n8n** (`POST` to `N8N_PUBLISH_WEBHOOK_URL`)
Headers: `Content-Type: application/json`, `X-Content-Secret: <N8N_WEBHOOK_SECRET>`
```jsonc
{
"draftId": "clx…",
"channel": "linkedin", // linkedin | blog | x | instagram
"projectKey": "sase",
"topicTitle": "Şase Numarası (VIN) Nedir?",
"content": { // effective body (founder edits merged over generated)
// linkedin: { body, hashtags[], cta? }
// blog: { title, slug, meta_description, body_markdown, tags[], cta? }
// x: { tweets[], hashtags[] }
// instagram:{ caption, hashtags[], image_prompt? }
}
}
```
**n8n → panel** (Respond to Webhook, synchronous)
```jsonc
{ "ok": true, "publishedUrl": "https://www.linkedin.com/feed/update/urn:li:share:123" }
// or
{ "ok": false, "error": "linkedin 401: token expired" }
```
The panel accepts `ok|success` true, and reads the URL from any of
`publishedUrl|url|postUrl|permalink`. A 2xx with no JSON is treated as success
without a URL.
## Panel env (set on panel-web in Coolify)
| key | value |
|-----|-------|
| `N8N_PUBLISH_WEBHOOK_URL` | `https://n8n.semih.ai/webhook/publish-content` (prod) — use `/webhook-test/publish-content` while building |
| `N8N_WEBHOOK_SECRET` | a long random string; also set as the n8n Header Auth credential value |
| `N8N_PUBLISH_TIMEOUT_MS` | optional, default `30000` |
After setting these, redeploy panel-web (push to main auto-deploys web).
## Build the workflow in n8n
Import `publish-content.workflow.json` (Workflows → Import from File) **or**
build these 5 nodes:
1. **Webhook**`POST`, path `publish-content`, **Respond** = "Using Respond
to Webhook node". Authentication = **Header Auth** → credential checking
header `X-Content-Secret` equals `N8N_WEBHOOK_SECRET`.
2. **Switch** (on `={{ $json.body.channel }}`): route `linkedin` and `blog`
(add `x` / `instagram` later). Add a fallback output → error response.
3. **LinkedIn** node (`linkedin` branch) — see OAuth setup below. Post text:
`={{ $json.body.content.body }}{{ $json.body.content.hashtags ? '\n\n' + $json.body.content.hashtags.join(' ') : '' }}`
4. **HTTP Request** (`blog` branch) — `POST {SASE_BLOG_API}/blog/posts/internal`,
Header Auth **`Authorization: Bearer <BLOG_AUTOMATION_TOKEN>`** (mirrors
sase.tr's existing `changelog/internal` automation pattern), JSON body =
`={{ $json.body.content }}` plus `{ "projectKey": "sase" }`.
5. **Respond to Webhook** (one per branch, or a shared Set→Respond) — return
`{ "ok": true, "publishedUrl": "<from node response>" }`. On the fallback /
error path return `{ "ok": false, "error": "<message>" }`.
Map `publishedUrl` from each node's response:
- LinkedIn node returns the share/ugcPost id → build `https://www.linkedin.com/feed/update/<urn>`.
- Blog HTTP returns `{ url }` from the sase.tr API (see below).
Activate the workflow (toggle top-right) to use the `/webhook/` (prod) path.
## LinkedIn OAuth (n8n credential)
1. **LinkedIn Developer** (https://www.linkedin.com/developers/) → Create app,
associate it with the **company page** you post from.
2. Products: request **"Share on LinkedIn"** and **"Advertising API"** /
**"Community Management API"** as needed for organization posting. Member
posting uses `w_member_social`; company-page posting uses
`w_organization_social` (needs page admin + may need app review).
3. Auth tab → add redirect URL: `https://n8n.semih.ai/rest/oauth2-credential/callback`.
4. In n8n → Credentials → **LinkedIn OAuth2 API** → paste Client ID/Secret,
set scopes (`w_member_social` and/or `w_organization_social r_organization_social`),
connect, authorize.
5. In the LinkedIn node pick "Post" and, for a company page, set
`Post As = Organization` + the organization URN.
> Note: organization posting often requires LinkedIn app review. Start with
> member posting (`w_member_social`) to validate end-to-end, then upgrade.
## Blog (sase.tr API) — see the sase.tr repo
The `blog` branch POSTs to the new sase.tr blog API
(`POST /blog/posts/internal`, header `Authorization: Bearer <BLOG_AUTOMATION_TOKEN>`
— mirrors sase.tr's existing `changelog/internal` automation auth). The
Drizzle model/endpoint live in the sase.tr codebase (`apps/api/src/blog`,
modeled on the `changelog` module). The API returns
`{ url: "https://sase.tr/blog/<slug>" }`, which n8n echoes back as
`publishedUrl`.
## Testing end-to-end
1. Set the panel envs to the **test** webhook URL, click "Listen for test event"
in the n8n Webhook node.
2. In the panel: approve a draft → **Yayınla**. Watch n8n execute; the panel
draft flips to `published` (with URL) or `failed` (with the error).
3. Switch the env to the prod `/webhook/` URL and **activate** the workflow.

View File

@@ -0,0 +1,160 @@
{
"name": "publish-content",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "publish-content",
"responseMode": "responseNode",
"authentication": "headerAuth",
"options": {}
},
"id": "webhook-in",
"name": "Webhook (publish-content)",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [-200, 300],
"webhookId": "publish-content",
"credentials": {
"httpHeaderAuth": { "id": "REPLACE", "name": "X-Content-Secret" }
}
},
{
"parameters": {
"rules": {
"values": [
{
"conditions": {
"options": { "caseSensitive": true, "typeValidation": "strict" },
"conditions": [
{
"id": "r-linkedin",
"leftValue": "={{ $json.body.channel }}",
"rightValue": "linkedin",
"operator": { "type": "string", "operation": "equals" }
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "linkedin"
},
{
"conditions": {
"options": { "caseSensitive": true, "typeValidation": "strict" },
"conditions": [
{
"id": "r-blog",
"leftValue": "={{ $json.body.channel }}",
"rightValue": "blog",
"operator": { "type": "string", "operation": "equals" }
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "blog"
}
]
},
"options": { "fallbackOutput": "extra" }
},
"id": "switch-channel",
"name": "Switch by channel",
"type": "n8n-nodes-base.switch",
"typeVersion": 3,
"position": [40, 300]
},
{
"parameters": {
"postAs": "person",
"text": "={{ $('Webhook (publish-content)').item.json.body.content.body }}{{ $('Webhook (publish-content)').item.json.body.content.hashtags ? '\\n\\n' + $('Webhook (publish-content)').item.json.body.content.hashtags.join(' ') : '' }}",
"additionalFields": {}
},
"id": "linkedin-post",
"name": "LinkedIn — create post",
"type": "n8n-nodes-base.linkedIn",
"typeVersion": 1,
"position": [300, 180],
"credentials": {
"linkedInOAuth2Api": { "id": "REPLACE", "name": "LinkedIn OAuth2" }
}
},
{
"parameters": {
"method": "POST",
"url": "={{ $env.SASE_BLOG_API }}/blog/posts/internal",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ { ...$('Webhook (publish-content)').item.json.body.content, projectKey: 'sase' } }}",
"options": {}
},
"id": "blog-post",
"name": "Blog — POST sase.tr",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [300, 360],
"credentials": {
"httpHeaderAuth": { "id": "REPLACE", "name": "SASE_BLOG_TOKEN (Bearer)" }
}
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ { ok: true, publishedUrl: ($json.permalink || $json.urn || $json.id || '') } }}",
"options": {}
},
"id": "respond-linkedin",
"name": "Respond — LinkedIn",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1,
"position": [560, 180]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ { ok: true, publishedUrl: ($json.url || $json.permalink || '') } }}",
"options": {}
},
"id": "respond-blog",
"name": "Respond — Blog",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1,
"position": [560, 360]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ { ok: false, error: 'unsupported channel: ' + $('Webhook (publish-content)').item.json.body.channel } }}",
"options": { "responseCode": 422 }
},
"id": "respond-fallback",
"name": "Respond — Unsupported",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1,
"position": [300, 540]
}
],
"connections": {
"Webhook (publish-content)": {
"main": [[{ "node": "Switch by channel", "type": "main", "index": 0 }]]
},
"Switch by channel": {
"main": [
[{ "node": "LinkedIn — create post", "type": "main", "index": 0 }],
[{ "node": "Blog — POST sase.tr", "type": "main", "index": 0 }],
[{ "node": "Respond — Unsupported", "type": "main", "index": 0 }]
]
},
"LinkedIn — create post": {
"main": [[{ "node": "Respond — LinkedIn", "type": "main", "index": 0 }]]
},
"Blog — POST sase.tr": {
"main": [[{ "node": "Respond — Blog", "type": "main", "index": 0 }]]
}
},
"settings": { "executionOrder": "v1" },
"active": false
}