From 34de3dfd90f48c569038586ef247e1a209ebfcac Mon Sep 17 00:00:00 2001 From: distroinfinity Date: Sat, 19 Sep 2026 11:00:25 -0700 Subject: [PATCH 01/35] chore(api): add carbon ads sdk --- packages/api/package.json | 1 + pnpm-lock.yaml | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/packages/api/package.json b/packages/api/package.json index ef061e1a..98c9e8ba 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -22,6 +22,7 @@ "db:seed": "tsx src/db/seed.ts" }, "dependencies": { + "@carbonads/sdk": "^0.2.2", "@distrotv/shared": "workspace:*", "@neondatabase/serverless": "^1.0.2", "@upstash/ratelimit": "^2.0.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3df6c460..9faecbe6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -144,6 +144,9 @@ importers: packages/api: dependencies: + '@carbonads/sdk': + specifier: ^0.2.2 + version: 0.2.2 '@distrotv/shared': specifier: workspace:* version: link:../shared @@ -359,6 +362,18 @@ packages: resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} + '@carbonads/sdk@0.2.2': + resolution: {integrity: sha512-+8tD1Ft5ewo5vKNMXYWmdQwhSnmuF+DEbu+qQ9x5bYJ/e4JKMCzZ823vdIoT1MMHPRF6XfRALlsY3dn6IJEanQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + ink: '>=6.0.0' + react: '>=19.0.0' + peerDependenciesMeta: + ink: + optional: true + react: + optional: true + '@clack/core@0.4.1': resolution: {integrity: sha512-Pxhij4UXg8KSr7rPek6Zowm+5M22rbd2g1nfojHJkxp5YkFqiZ2+YLEM/XGVIzvGOcM0nqjIFxrpDwWRZYWYjA==} @@ -5083,6 +5098,8 @@ snapshots: '@babel/runtime@7.29.2': {} + '@carbonads/sdk@0.2.2': {} + '@clack/core@0.4.1': dependencies: picocolors: 1.1.1 From f3230e71522c4036d145b2020f8eb6bb892ae235 Mon Sep 17 00:00:00 2001 From: distroinfinity Date: Sat, 19 Sep 2026 11:05:31 -0700 Subject: [PATCH 02/35] feat: sponsored slot payload, feeds preference, status line panel --- .../lib/__tests__/render-sponsored.test.ts | 61 +++++++++++++++++++ packages/cli/src/lib/render-line.ts | 55 +++-------------- packages/cli/src/lib/render-sponsored.ts | 55 +++++++++++++++++ packages/cli/src/lib/render-utils.ts | 48 +++++++++++++++ packages/shared/src/__tests__/feeds.test.ts | 16 +++++ packages/shared/src/constants/index.ts | 10 ++- packages/shared/src/types/SlotPayload.ts | 10 ++- packages/shared/src/types/SponsoredPayload.ts | 19 ++++++ packages/shared/src/types/index.ts | 4 ++ 9 files changed, 223 insertions(+), 55 deletions(-) create mode 100644 packages/cli/src/lib/__tests__/render-sponsored.test.ts create mode 100644 packages/cli/src/lib/render-sponsored.ts create mode 100644 packages/cli/src/lib/render-utils.ts create mode 100644 packages/shared/src/__tests__/feeds.test.ts create mode 100644 packages/shared/src/types/SponsoredPayload.ts diff --git a/packages/cli/src/lib/__tests__/render-sponsored.test.ts b/packages/cli/src/lib/__tests__/render-sponsored.test.ts new file mode 100644 index 00000000..6ab5c0fb --- /dev/null +++ b/packages/cli/src/lib/__tests__/render-sponsored.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from "vitest" +import type { SponsoredPayload } from "@distrotv/shared" +import { renderSlotLine } from "../render-line.js" +import { perImpressionUsd } from "../render-sponsored.js" + +const ad: SponsoredPayload & { cacheSource: "api" } = { + kind: "sponsored", + adId: "house:sentry", + source: "carbon", + advertiser: "Sentry", + headline: "See the error, the commit, and the fix. Monitoring built for developers.", + ctaText: "Try Sentry", + displayUrl: "sentry.io", + clickUrl: "http://localhost:3011/ads/click/3f0c2f0e-7f1a-4b57-9d9e-0d8f4f3f2a11", + deliveryId: "3f0c2f0e-7f1a-4b57-9d9e-0d8f4f3f2a11", + cpmRate: 10, + cacheSource: "api", +} +const strip = (s: string): string => + s.replace(/\x1b\][^\x1b]*\x1b\\/g, "").replace(/\x1b\[[0-9;]*m/g, "") + +describe("sponsored panel", () => { + it("estimates the per-impression share", () => { + expect(perImpressionUsd(10)).toBeCloseTo(0.007, 6) + }) + it("shows label, advertiser, copy, link, command and estimates", () => { + const out = renderSlotLine(ad, "none", 80, undefined, { earnedTodayUsd: 0.42 }) + expect(out).toContain("sponsored") + expect(out).toContain("via Carbon") + expect(out).toContain("Sentry") + expect(out).toContain("See the error") + expect(out).toContain("sentry.io") + expect(out).toContain("dtv open") + expect(out).toContain("+$0.0070 est") + expect(out).toContain("today $0.42") + }) + it("says demo for house ads", () => { + expect(renderSlotLine({ ...ad, source: "house" }, "none", 80)).toContain("via Distro") + }) + it.each([60, 80, 120])("fits width %i and stays within 6 lines", (w) => { + const lines = strip( + renderSlotLine(ad, "truecolor", w, undefined, { earnedTodayUsd: 1.5, hyperlinks: true }) + ).split("\n") + expect(lines.length).toBeLessThanOrEqual(6) + for (const l of lines) expect([...l].length).toBeLessThanOrEqual(w) + }) + it("wraps the link in an OSC 8 hyperlink only when enabled", () => { + expect(renderSlotLine(ad, "truecolor", 80, undefined, { hyperlinks: true })).toContain( + "\x1b]8;;http://localhost:3011/ads/click/" + ) + expect(renderSlotLine(ad, "truecolor", 80, undefined, { hyperlinks: false })).not.toContain( + "\x1b]8;;" + ) + }) + it("strips control characters from ad copy", () => { + const out = renderSlotLine({ ...ad, headline: "hi\x1b[31m there\x07" }, "none", 80) + expect(out).not.toContain("\x07") + expect(out).not.toContain("\x1b[31m") + expect(out).toContain("hi there") + }) +}) diff --git a/packages/cli/src/lib/render-line.ts b/packages/cli/src/lib/render-line.ts index b4522822..2b6266b5 100644 --- a/packages/cli/src/lib/render-line.ts +++ b/packages/cli/src/lib/render-line.ts @@ -3,6 +3,8 @@ import type { CachedSlot } from "./slot-cache.js" import { bgRgb, color, rgb, type ColorMode } from "./ansi.js" import { renderChip, getBrandName, chipLabelFor } from "./brand-colors.js" import { renderChart, directionFor } from "./sparkline.js" +import { LEFT_PAD, spread, visLen, wrapHeadline } from "./render-utils.js" +import { renderSponsoredPanel, type RenderExtras } from "./render-sponsored.js" // Multi-line, colored slot panel for Claude Code's statusLine. Mirrors the old // box's look (indigo bar, brand chip badge, colored change, braille sparkline, @@ -13,21 +15,9 @@ import { renderChart, directionFor } from "./sparkline.js" // Returned as a "\n"-joined string; the daemon stores it in now-playing.json and // `distro statusline` prints it verbatim, so Claude renders each line as a row. -const ANSI_RE = /\x1b\[[0-9;]*m/g const MIN_WIDTH = 48 const MAX_WIDTH = 120 const HEADLINE_MAX_LINES = 2 -const LEFT_PAD = " " - -function visLen(s: string): number { - return [...s.replace(ANSI_RE, "")].length -} - -// left … right, right-justified to `width` (min 3-space gap so they never touch) -function spread(left: string, right: string, width: number): string { - const gap = Math.max(3, width - visLen(left) - visLen(right)) - return left + " ".repeat(gap) + right -} function age(seconds: number): string { if (seconds < 60) return "now" @@ -47,40 +37,6 @@ function pct(n: number): string { return `${n >= 0 ? "+" : "−"}${Math.abs(n).toFixed(2)}%` } -// Word-wrap into up to `maxLines`, ellipsizing the last line if it overflows. -function wrapHeadline(text: string, width: number, maxLines: number): string[] { - const words = text.split(/\s+/).filter(Boolean) - const lines: string[] = [] - let current = "" - let consumed = 0 - for (const word of words) { - if (current.length === 0) { - current = word - consumed++ - continue - } - if (current.length + 1 + word.length <= width) { - current += " " + word - consumed++ - } else { - lines.push(current) - if (lines.length >= maxLines) { - current = "" - break - } - current = word - consumed++ - } - } - if (current.length > 0 && lines.length < maxLines) lines.push(current) - if (consumed < words.length && lines.length > 0) { - const last = lines[lines.length - 1] as string - const trimmed = last.length >= width ? last.slice(0, width - 1) : last - lines[lines.length - 1] = trimmed + "…" - } - return lines -} - // One-line update prompt shown above the slot heading when a newer CLI exists. function updateNudge(latest: string, mode: ColorMode): string { return `${color("warning", "↑", mode)} ${color("muted", `distro tv ${latest} available · curl -fsSL https://get.distrotv.xyz/install.sh | sh`, mode)}` @@ -90,12 +46,17 @@ export function renderSlotLine( slot: CachedSlot, mode: ColorMode = "truecolor", width = 80, - updateLatest?: string + updateLatest?: string, + extras: RenderExtras = {} ): string { const W = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, width)) const dot = color("muted", "·", mode) const nudge = updateLatest ? [updateNudge(updateLatest, mode)] : [] + if (slot.kind === "sponsored") { + return renderSponsoredPanel(slot, mode, W, nudge, extras) + } + if (slot.kind === "utility") { return renderUtilityPanel(slot, mode, W, nudge) } diff --git a/packages/cli/src/lib/render-sponsored.ts b/packages/cli/src/lib/render-sponsored.ts new file mode 100644 index 00000000..b5ed2832 --- /dev/null +++ b/packages/cli/src/lib/render-sponsored.ts @@ -0,0 +1,55 @@ +import { REVENUE_SHARE_DEVELOPER, type SponsoredPayload } from "@distrotv/shared" +import { color, type ColorMode } from "./ansi.js" +import { LEFT_PAD, spread, wrapHeadline } from "./render-utils.js" + +export interface RenderExtras { + earnedTodayUsd?: number + // OSC 8 hyperlinks — only when the status line passes them through + hyperlinks?: boolean +} + +export function perImpressionUsd(cpmRate: number): number { + return (cpmRate / 1000) * REVENUE_SHARE_DEVELOPER +} + +// ad copy is third-party text: drop escape sequences and control bytes before it reaches the tty. +function clean(s: string): string { + return s + .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "") + .replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, "") + .replace(/[\x00-\x1f\x7f]/g, "") +} + +function link(text: string, url: string, on: boolean): string { + return on ? `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\` : text +} + +export function renderSponsoredPanel( + slot: SponsoredPayload, + mode: ColorMode, + W: number, + nudge: string[], + extras: RenderExtras = {} +): string { + const dot = color("muted", "·", mode) + const via = slot.source === "carbon" ? "via Carbon" : "via Distro · demo" + const headerLeft = `${color("indigo", "▍", mode)} ${color("indigo", "sponsored", mode)} ${dot} ${color("muted", via, mode)}` + const headerRight = color("positive", `+$${perImpressionUsd(slot.cpmRate).toFixed(4)} est`, mode) + const header = spread(headerLeft, headerRight, W) + + const advertiser = `${LEFT_PAD}${color("fg", clean(slot.advertiser), mode)}` + const copy = wrapHeadline(clean(slot.headline), W - LEFT_PAD.length, 2).map( + (l) => `${LEFT_PAD}${color("fg", l, mode)}` + ) + + const useLinks = extras.hyperlinks === true && mode !== "none" + const host = link(color("indigo", `↗ ${clean(slot.displayUrl)}`, mode), slot.clickUrl, useLinks) + const today = + extras.earnedTodayUsd != null + ? ` ${dot} ${color("muted", `today $${extras.earnedTodayUsd.toFixed(2)}`, mode)}` + : "" + // no spread() on this line — OSC 8 bytes would break its width maths + const action = `${LEFT_PAD}${host} ${dot} ${color("muted", "dtv open", mode)}${today}` + + return [...nudge, header, advertiser, ...copy, action].join("\n") +} diff --git a/packages/cli/src/lib/render-utils.ts b/packages/cli/src/lib/render-utils.ts new file mode 100644 index 00000000..1048a340 --- /dev/null +++ b/packages/cli/src/lib/render-utils.ts @@ -0,0 +1,48 @@ +// shared layout helpers for the status-line panels. + +const ANSI_RE = /\x1b\[[0-9;]*m/g +export const LEFT_PAD = " " + +export function visLen(s: string): number { + return [...s.replace(ANSI_RE, "")].length +} + +// left … right, right-justified to `width` (min 3-space gap so they never touch) +export function spread(left: string, right: string, width: number): string { + const gap = Math.max(3, width - visLen(left) - visLen(right)) + return left + " ".repeat(gap) + right +} + +// Word-wrap into up to `maxLines`, ellipsizing the last line if it overflows. +export function wrapHeadline(text: string, width: number, maxLines: number): string[] { + const words = text.split(/\s+/).filter(Boolean) + const lines: string[] = [] + let current = "" + let consumed = 0 + for (const word of words) { + if (current.length === 0) { + current = word + consumed++ + continue + } + if (current.length + 1 + word.length <= width) { + current += " " + word + consumed++ + } else { + lines.push(current) + if (lines.length >= maxLines) { + current = "" + break + } + current = word + consumed++ + } + } + if (current.length > 0 && lines.length < maxLines) lines.push(current) + if (consumed < words.length && lines.length > 0) { + const last = lines[lines.length - 1] as string + const trimmed = last.length >= width ? last.slice(0, width - 1) : last + lines[lines.length - 1] = trimmed + "…" + } + return lines +} diff --git a/packages/shared/src/__tests__/feeds.test.ts b/packages/shared/src/__tests__/feeds.test.ts new file mode 100644 index 00000000..aa8f78ed --- /dev/null +++ b/packages/shared/src/__tests__/feeds.test.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from "vitest" +import { defaultPreferences, DEFAULT_FEEDS, FEEDS, AD_SLOT_EVERY_N } from "../index.js" + +describe("feeds defaults", () => { + it("ads are the only default feed", () => { + expect(DEFAULT_FEEDS).toEqual(["ads"]) + expect(defaultPreferences().enabledFeeds).toEqual(["ads"]) + }) + it("utilities are off by default", () => { + expect(defaultPreferences().utilitiesEnabled).toBe(false) + }) + it("exposes the feed list and ad cadence", () => { + expect(FEEDS).toEqual(["ads", "news", "markets"]) + expect(AD_SLOT_EVERY_N).toBe(2) + }) +}) diff --git a/packages/shared/src/constants/index.ts b/packages/shared/src/constants/index.ts index 50bd2ae2..e9b60c85 100644 --- a/packages/shared/src/constants/index.ts +++ b/packages/shared/src/constants/index.ts @@ -1,4 +1,4 @@ -import type { DevdripPreferences } from "../types/index.js" +import type { DevdripPreferences, Feed } from "../types/index.js" import { ChannelMode } from "../types/index.js" // ── timing ────────────────────────────────────────────────────────────────── @@ -7,6 +7,11 @@ export const GRACE_PERIOD_MS = 0 // every slot (news + ticker) shows for this long before rotating. export const MAX_AD_DURATION_MS = 12_000 +export const FEEDS: Feed[] = ["ads", "news", "markets"] +export const DEFAULT_FEEDS: Feed[] = ["ads"] +// with content feeds on, every Nth slot is an ad (starting with an ad) +export const AD_SLOT_EVERY_N = 2 + // ── CH 03 Utilities ──────────────────────────────────────────────────────── // Inject a locally-built utility panel every Nth slot pick (news → ticker → // utils → …). 3 keeps it present without crowding out news/markets. @@ -107,12 +112,13 @@ export function defaultPreferences(): DevdripPreferences { nightMode: false, channelMode: ChannelMode.Balanced, newsTopics: [], + enabledFeeds: [...DEFAULT_FEEDS], tzOffsetMinutes: -new Date().getTimezoneOffset(), idleSensitivityMs: IDLE_SENSITIVITY_MS, // Sentinel "never synced" — first GET /me/preferences will replace it. updatedAt: new Date(0).toISOString(), muteUntil: null, - utilitiesEnabled: true, + utilitiesEnabled: false, utilitiesLayout: "auto", } } diff --git a/packages/shared/src/types/SlotPayload.ts b/packages/shared/src/types/SlotPayload.ts index c1cc9cb7..1d512d35 100644 --- a/packages/shared/src/types/SlotPayload.ts +++ b/packages/shared/src/types/SlotPayload.ts @@ -1,13 +1,11 @@ import type { NewsPayload } from "./NewsPayload.js" import type { TickerPayload } from "./TickerPayload.js" import type { UtilityPayload } from "./UtilityPayload.js" +import type { SponsoredPayload } from "./SponsoredPayload.js" export type SlotKind = "news" | "ticker" | "utility" | "sponsored" | "portfolio" export type SlotLayout = "single" | "grid" -// SlotKind includes "sponsored" and "portfolio" as reserved future kinds. -// Their payload types do not exist yet, so a discriminant check on those -// values will narrow SlotPayload to `never`. Add the payload types when -// these kinds become live. "utility" (CH 03) is locally generated by the -// daemon — never returned by /me/content/next. -export type SlotPayload = NewsPayload | TickerPayload | UtilityPayload +// "portfolio" is a reserved future kind with no payload type yet. "utility" +// (CH 03) is generated locally by the daemon — never returned by /me/content/next. +export type SlotPayload = NewsPayload | TickerPayload | UtilityPayload | SponsoredPayload diff --git a/packages/shared/src/types/SponsoredPayload.ts b/packages/shared/src/types/SponsoredPayload.ts new file mode 100644 index 00000000..e0c568fd --- /dev/null +++ b/packages/shared/src/types/SponsoredPayload.ts @@ -0,0 +1,19 @@ +export type Feed = "ads" | "news" | "markets" + +export interface SponsoredPayload { + kind: "sponsored" + // "carbon:" | "house:" + adId: string + source: "carbon" | "house" + advertiser: string + headline: string + ctaText: string + // short label for the link, e.g. "sentry.io" (advertiser name when the host is a tracker) + displayUrl: string + // our redirect: {apiUrl}/ads/click/{deliveryId} + clickUrl: string + // opaque uuid, one per served ad + deliveryId: string + // estimated usd cpm + cpmRate: number +} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 5a930665..42ab89c8 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -28,6 +28,7 @@ export enum ChannelMode { } import type { NewsTopic } from "./news.js" +import type { Feed } from "./SponsoredPayload.js" export { NewsSource, NewsTopic } from "./news.js" export type { ChannelKey } from "./news.js" @@ -53,6 +54,8 @@ export interface SyncedPreferences { nightMode: boolean channelMode: ChannelMode newsTopics: NewsTopic[] + // which feeds rotate in the terminal. default ["ads"]; news/markets are opt-in. + enabledFeeds: Feed[] // ISO 8601, set by server on every write. clients never set this. updatedAt: string } @@ -88,6 +91,7 @@ export interface Device { export type { NewsPayload } from "./NewsPayload.js" export type { TickerPayload, TickerStats } from "./TickerPayload.js" export type { SlotPayload, SlotKind, SlotLayout } from "./SlotPayload.js" +export type { SponsoredPayload, Feed } from "./SponsoredPayload.js" export type { UtilityPayload, UtilityLayout, From 99750a911cbf1ceeb1c28c93703f5eb59f242158 Mon Sep 17 00:00:00 2001 From: distroinfinity Date: Sat, 19 Sep 2026 11:06:03 -0700 Subject: [PATCH 03/35] feat(api): ad_impressions table + enabled_feeds preference --- .../db/migrations/0023_steep_shockwave.sql | 24 + .../src/db/migrations/meta/0023_snapshot.json | 1610 +++++++++++++++++ .../api/src/db/migrations/meta/_journal.json | 7 + packages/api/src/db/schema/ad_impressions.ts | 46 + packages/api/src/db/schema/index.ts | 1 + packages/api/src/db/schema/preferences.ts | 2 + 6 files changed, 1690 insertions(+) create mode 100644 packages/api/src/db/migrations/0023_steep_shockwave.sql create mode 100644 packages/api/src/db/migrations/meta/0023_snapshot.json create mode 100644 packages/api/src/db/schema/ad_impressions.ts diff --git a/packages/api/src/db/migrations/0023_steep_shockwave.sql b/packages/api/src/db/migrations/0023_steep_shockwave.sql new file mode 100644 index 00000000..acf92a1d --- /dev/null +++ b/packages/api/src/db/migrations/0023_steep_shockwave.sql @@ -0,0 +1,24 @@ +CREATE TABLE "ad_impressions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "device_id" uuid NOT NULL, + "delivery_id" uuid NOT NULL, + "ad_id" text NOT NULL, + "source" text NOT NULL, + "advertiser" text NOT NULL, + "headline" text NOT NULL, + "target_url" text NOT NULL, + "duration_ms" integer DEFAULT 0 NOT NULL, + "result" text DEFAULT 'pending' NOT NULL, + "clicked" boolean DEFAULT false NOT NULL, + "clicked_at" timestamp with time zone, + "cpm_rate" numeric(12, 6) NOT NULL, + "earned_amount" numeric(12, 6) DEFAULT '0' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "preferences" ADD COLUMN "enabled_feeds" text[] DEFAULT '{"ads"}' NOT NULL;--> statement-breakpoint +ALTER TABLE "ad_impressions" ADD CONSTRAINT "ad_impressions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ad_impressions" ADD CONSTRAINT "ad_impressions_device_id_devices_id_fk" FOREIGN KEY ("device_id") REFERENCES "public"."devices"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "ad_impressions_delivery_uq" ON "ad_impressions" USING btree ("delivery_id");--> statement-breakpoint +CREATE INDEX "ad_impressions_user_created_idx" ON "ad_impressions" USING btree ("user_id","created_at"); \ No newline at end of file diff --git a/packages/api/src/db/migrations/meta/0023_snapshot.json b/packages/api/src/db/migrations/meta/0023_snapshot.json new file mode 100644 index 00000000..f3488bf0 --- /dev/null +++ b/packages/api/src/db/migrations/meta/0023_snapshot.json @@ -0,0 +1,1610 @@ +{ + "id": "84e8baf9-16b8-4b48-9734-3f32ac35446d", + "prevId": "2f90169b-df77-40de-a386-b1c80a4768a3", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "github_id": { + "name": "github_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "github_login": { + "name": "github_login", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "repos_count": { + "name": "repos_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "primary_language": { + "name": "primary_language", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "signed_up_at": { + "name": "signed_up_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_github_id_unique": { + "name": "users_github_id_unique", + "nullsNotDistinct": false, + "columns": ["github_id"] + }, + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.devices": { + "name": "devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "machine_id_hash": { + "name": "machine_id_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "device_name": { + "name": "device_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "os": { + "name": "os", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "ide_type": { + "name": "ide_type", + "type": "ide_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "cli_version": { + "name": "cli_version", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "device_secret_hash": { + "name": "device_secret_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_heartbeat": { + "name": "last_heartbeat", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "devices_user_machine_idx": { + "name": "devices_user_machine_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "machine_id_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "devices_user_id_users_id_fk": { + "name": "devices_user_id_users_id_fk", + "tableFrom": "devices", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.preferences": { + "name": "preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "blocked_categories": { + "name": "blocked_categories", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "enabled_surfaces": { + "name": "enabled_surfaces", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "max_per_hour": { + "name": "max_per_hour", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 9999 + }, + "max_per_day": { + "name": "max_per_day", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 99999 + }, + "quiet_hours_start": { + "name": "quiet_hours_start", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "quiet_hours_end": { + "name": "quiet_hours_end", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tz_offset_minutes": { + "name": "tz_offset_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "idle_sensitivity_ms": { + "name": "idle_sensitivity_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "session_warmup_ms": { + "name": "session_warmup_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "night_mode": { + "name": "night_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "channel_mode": { + "name": "channel_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'balanced'" + }, + "news_topics": { + "name": "news_topics", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "enabled_feeds": { + "name": "enabled_feeds", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{\"ads\"}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "preferences_user_id_users_id_fk": { + "name": "preferences_user_id_users_id_fk", + "tableFrom": "preferences", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "preferences_user_id_unique": { + "name": "preferences_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slot_impressions": { + "name": "slot_impressions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "news_id": { + "name": "news_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "opened_url": { + "name": "opened_url", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "saved": { + "name": "saved", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'news'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slot_impressions_user_id_idx": { + "name": "slot_impressions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slot_impressions_user_created_at_idx": { + "name": "slot_impressions_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slot_impressions_user_id_users_id_fk": { + "name": "slot_impressions_user_id_users_id_fk", + "tableFrom": "slot_impressions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "slot_impressions_device_id_devices_id_fk": { + "name": "slot_impressions_device_id_devices_id_fk", + "tableFrom": "slot_impressions", + "tableTo": "devices", + "columnsFrom": ["device_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reading_list_items": { + "name": "reading_list_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "news_id": { + "name": "news_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "headline": { + "name": "headline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "saved_at": { + "name": "saved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "reading_user_saved_idx": { + "name": "reading_user_saved_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "saved_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reading_user_news_unique": { + "name": "reading_user_news_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "news_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reading_list_items_user_id_users_id_fk": { + "name": "reading_list_items_user_id_users_id_fk", + "tableFrom": "reading_list_items", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channels": { + "name": "channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_on": { + "name": "default_on", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "channels_key_uq": { + "name": "channels_key_uq", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_subscriptions": { + "name": "channel_subscriptions", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "channel_subscriptions_user_idx": { + "name": "channel_subscriptions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "channel_subscriptions_user_id_users_id_fk": { + "name": "channel_subscriptions_user_id_users_id_fk", + "tableFrom": "channel_subscriptions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_subscriptions_channel_id_channels_id_fk": { + "name": "channel_subscriptions_channel_id_channels_id_fk", + "tableFrom": "channel_subscriptions", + "tableTo": "channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_subscriptions_user_id_channel_id_pk": { + "name": "channel_subscriptions_user_id_channel_id_pk", + "columns": ["user_id", "channel_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.news_sources": { + "name": "news_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "half_life_hours": { + "name": "half_life_hours", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 12 + }, + "fetch_interval_min": { + "name": "fetch_interval_min", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "healthy": { + "name": "healthy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_fetched_at": { + "name": "last_fetched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "news_sources_key_uq": { + "name": "news_sources_key_uq", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "news_sources_channel_id_channels_id_fk": { + "name": "news_sources_channel_id_channels_id_fk", + "tableFrom": "news_sources", + "tableTo": "channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.news_items": { + "name": "news_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "headline": { + "name": "headline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "comments_url": { + "name": "comments_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "comments_count": { + "name": "comments_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "fetched_at": { + "name": "fetched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "news_items_channel_published_idx": { + "name": "news_items_channel_published_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "published_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "news_items_published_idx": { + "name": "news_items_published_idx", + "columns": [ + { + "expression": "published_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "news_items_channel_id_channels_id_fk": { + "name": "news_items_channel_id_channels_id_fk", + "tableFrom": "news_items", + "tableTo": "channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "news_items_source_id_news_sources_id_fk": { + "name": "news_items_source_id_news_sources_id_fk", + "tableFrom": "news_items", + "tableTo": "news_sources", + "columnsFrom": ["source_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.watchlists": { + "name": "watchlists", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "watchlists_user_name_uq": { + "name": "watchlists_user_name_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "watchlists_user_idx": { + "name": "watchlists_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "watchlists_user_id_users_id_fk": { + "name": "watchlists_user_id_users_id_fk", + "tableFrom": "watchlists", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.watchlist_tickers": { + "name": "watchlist_tickers", + "schema": "", + "columns": { + "watchlist_id": { + "name": "watchlist_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "symbol": { + "name": "symbol", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "asset_class": { + "name": "asset_class", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "watchlist_tickers_symbol_idx": { + "name": "watchlist_tickers_symbol_idx", + "columns": [ + { + "expression": "symbol", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "watchlist_tickers_watchlist_id_watchlists_id_fk": { + "name": "watchlist_tickers_watchlist_id_watchlists_id_fk", + "tableFrom": "watchlist_tickers", + "tableTo": "watchlists", + "columnsFrom": ["watchlist_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "watchlist_tickers_watchlist_id_symbol_pk": { + "name": "watchlist_tickers_watchlist_id_symbol_pk", + "columns": ["watchlist_id", "symbol"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.alerts": { + "name": "alerts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "symbol": { + "name": "symbol", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "threshold_pct": { + "name": "threshold_pct", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "alerts_user_symbol_uq": { + "name": "alerts_user_symbol_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "symbol", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"alerts\".\"symbol\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "alerts_user_global_uq": { + "name": "alerts_user_global_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"alerts\".\"symbol\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "alerts_user_idx": { + "name": "alerts_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "alerts_user_id_users_id_fk": { + "name": "alerts_user_id_users_id_fk", + "tableFrom": "alerts", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.alert_events": { + "name": "alert_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "symbol": { + "name": "symbol", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "change_pct": { + "name": "change_pct", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "threshold_pct": { + "name": "threshold_pct", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "fired_at": { + "name": "fired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "alert_events_device_symbol_fired_idx": { + "name": "alert_events_device_symbol_fired_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "symbol", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"fired_at\" desc", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_events_user_fired_idx": { + "name": "alert_events_user_fired_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"fired_at\" desc", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "alert_events_user_id_users_id_fk": { + "name": "alert_events_user_id_users_id_fk", + "tableFrom": "alert_events", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "alert_events_device_id_devices_id_fk": { + "name": "alert_events_device_id_devices_id_fk", + "tableFrom": "alert_events", + "tableTo": "devices", + "columnsFrom": ["device_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ad_impressions": { + "name": "ad_impressions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "delivery_id": { + "name": "delivery_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ad_id": { + "name": "ad_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "advertiser": { + "name": "advertiser", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "headline": { + "name": "headline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_url": { + "name": "target_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "clicked": { + "name": "clicked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "clicked_at": { + "name": "clicked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cpm_rate": { + "name": "cpm_rate", + "type": "numeric(12, 6)", + "primaryKey": false, + "notNull": true + }, + "earned_amount": { + "name": "earned_amount", + "type": "numeric(12, 6)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ad_impressions_delivery_uq": { + "name": "ad_impressions_delivery_uq", + "columns": [ + { + "expression": "delivery_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ad_impressions_user_created_idx": { + "name": "ad_impressions_user_created_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ad_impressions_user_id_users_id_fk": { + "name": "ad_impressions_user_id_users_id_fk", + "tableFrom": "ad_impressions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ad_impressions_device_id_devices_id_fk": { + "name": "ad_impressions_device_id_devices_id_fk", + "tableFrom": "ad_impressions", + "tableTo": "devices", + "columnsFrom": ["device_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.ide_type": { + "name": "ide_type", + "schema": "public", + "values": ["terminal", "vscode", "cursor"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/api/src/db/migrations/meta/_journal.json b/packages/api/src/db/migrations/meta/_journal.json index bb7b9956..86df5ace 100644 --- a/packages/api/src/db/migrations/meta/_journal.json +++ b/packages/api/src/db/migrations/meta/_journal.json @@ -162,6 +162,13 @@ "when": 1779693901246, "tag": "0022_charming_wilson_fisk", "breakpoints": true + }, + { + "idx": 23, + "version": "7", + "when": 1789841152669, + "tag": "0023_steep_shockwave", + "breakpoints": true } ] } diff --git a/packages/api/src/db/schema/ad_impressions.ts b/packages/api/src/db/schema/ad_impressions.ts new file mode 100644 index 00000000..496469e9 --- /dev/null +++ b/packages/api/src/db/schema/ad_impressions.ts @@ -0,0 +1,46 @@ +import { + pgTable, + uuid, + text, + integer, + boolean, + numeric, + timestamp, + index, + uniqueIndex, +} from "drizzle-orm/pg-core" +import { users } from "./users.js" +import { devices } from "./devices.js" + +// one row per served ad that was seen or clicked. seed of the exchange ledger: +// slot, audience (user/device), impression, click, price. +export const adImpressions = pgTable( + "ad_impressions", + { + id: uuid("id").primaryKey().defaultRandom(), + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + deviceId: uuid("device_id") + .notNull() + .references(() => devices.id, { onDelete: "cascade" }), + deliveryId: uuid("delivery_id").notNull(), + adId: text("ad_id").notNull(), + source: text("source").notNull(), + advertiser: text("advertiser").notNull(), + headline: text("headline").notNull(), + targetUrl: text("target_url").notNull(), + durationMs: integer("duration_ms").notNull().default(0), + // ImpressionResult, or "pending" when a click arrived before the impression synced + result: text("result").notNull().default("pending"), + clicked: boolean("clicked").notNull().default(false), + clickedAt: timestamp("clicked_at", { withTimezone: true }), + cpmRate: numeric("cpm_rate", { precision: 12, scale: 6 }).notNull(), + earnedAmount: numeric("earned_amount", { precision: 12, scale: 6 }).notNull().default("0"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (t) => ({ + deliveryUq: uniqueIndex("ad_impressions_delivery_uq").on(t.deliveryId), + userCreatedIdx: index("ad_impressions_user_created_idx").on(t.userId, t.createdAt), + }) +) diff --git a/packages/api/src/db/schema/index.ts b/packages/api/src/db/schema/index.ts index d83f6d7e..9336c7a3 100644 --- a/packages/api/src/db/schema/index.ts +++ b/packages/api/src/db/schema/index.ts @@ -14,3 +14,4 @@ export * from "./watchlist_tickers.js" export * from "./alerts.js" export * from "./alert_events.js" // ticker_symbol_map schema removed (no internal symbol↔provider map needed) +export * from "./ad_impressions.js" diff --git a/packages/api/src/db/schema/preferences.ts b/packages/api/src/db/schema/preferences.ts index 5c3e89ed..b595ff40 100644 --- a/packages/api/src/db/schema/preferences.ts +++ b/packages/api/src/db/schema/preferences.ts @@ -20,6 +20,8 @@ export const preferences = pgTable("preferences", { // default 'balanced' matches shared defaultPreferences() channelMode: text("channel_mode").notNull().default("balanced"), newsTopics: text("news_topics").array().notNull().default([]), + // default '{ads}' matches shared DEFAULT_FEEDS + enabledFeeds: text("enabled_feeds").array().notNull().default(["ads"]), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }) From 97c500f58176be1c4d43dacee59e9af7bc629c34 Mon Sep 17 00:00:00 2001 From: distroinfinity Date: Sat, 19 Sep 2026 11:08:11 -0700 Subject: [PATCH 04/35] =?UTF-8?q?feat(api):=20ad=20supply=20=E2=80=94=20ca?= =?UTF-8?q?rbon=20provider,=20house=20fallback,=20delivery=20rows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/api/.env.example | 5 + packages/api/src/__tests__/ad-supply.test.ts | 72 + packages/api/src/config/env.ts | 12 + .../migrations/0024_chemical_enchantress.sql | 2 + .../src/db/migrations/meta/0024_snapshot.json | 1622 +++++++++++++++++ .../api/src/db/migrations/meta/_journal.json | 7 + packages/api/src/db/schema/ad_impressions.ts | 11 +- .../api/src/services/ad-supply.service.ts | 63 + .../api/src/services/carbon-ad.provider.ts | 101 + packages/api/src/services/house-ads.ts | 86 + 10 files changed, 1978 insertions(+), 3 deletions(-) create mode 100644 packages/api/src/__tests__/ad-supply.test.ts create mode 100644 packages/api/src/db/migrations/0024_chemical_enchantress.sql create mode 100644 packages/api/src/db/migrations/meta/0024_snapshot.json create mode 100644 packages/api/src/services/ad-supply.service.ts create mode 100644 packages/api/src/services/carbon-ad.provider.ts create mode 100644 packages/api/src/services/house-ads.ts diff --git a/packages/api/.env.example b/packages/api/.env.example index 7024dff0..e38ec95d 100644 --- a/packages/api/.env.example +++ b/packages/api/.env.example @@ -56,3 +56,8 @@ ADMIN_SECRET=replace-with-random-hex-string FINNHUB_API_KEY=dev_placeholder # M7 will add: ADMIN_EMAILS, POSTHOG_API_KEY, SLACK_WEBHOOK_URL + +# ads — empty CARBON_ZONE_KEY uses carbon's demo zone. AD_CPM_RATE is the estimated usd cpm. +CARBON_ZONE_KEY= +CARBON_PLACEMENT=distrotv +AD_CPM_RATE=10 diff --git a/packages/api/src/__tests__/ad-supply.test.ts b/packages/api/src/__tests__/ad-supply.test.ts new file mode 100644 index 00000000..8a64da18 --- /dev/null +++ b/packages/api/src/__tests__/ad-supply.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from "vitest" +import { mapCarbonAd, displayLabel } from "../services/carbon-ad.provider.js" +import { houseAd, HOUSE_ADS } from "../services/house-ads.js" + +function must(v: T | null | undefined): T { + if (v == null) throw new Error("expected a value") + return v +} + +describe("mapCarbonAd", () => { + const base = { + company: "Sentry", + description: "Fix errors before users notice. Application monitoring for developers.", + link: "https://sentry.io/welcome", + statlink: "https://srv.carbonads.net/ads/click/x/abc", + statviewUrl: "https://srv.carbonads.net/ads/view/x/abc", + callToAction: "Try Sentry", + } + it("maps fields and derives a stable id", () => { + const a = must(mapCarbonAd(base)) + expect(a.source).toBe("carbon") + expect(a.advertiser).toBe("Sentry") + expect(a.headline).toContain("Fix errors") + expect(a.ctaText).toBe("Try Sentry") + expect(a.targetUrl).toBe("https://sentry.io/welcome") + expect(a.clickBeaconUrl).toBe(base.statlink) + expect(a.viewBeaconUrl).toBe(base.statviewUrl) + expect(a.adId).toMatch(/^carbon:[0-9a-f]{32}$/) + expect(must(mapCarbonAd(base)).adId).toBe(a.adId) + }) + it("does not double count when the link already is the tracking url", () => { + // what carbon's demo zone returns: link === statlink, empty statview + const a = must(mapCarbonAd({ ...base, link: base.statlink, statviewUrl: "" })) + expect(a.targetUrl).toBe(base.statlink) + expect(a.clickBeaconUrl).toBeNull() + expect(a.viewBeaconUrl).toBeNull() + }) + it("returns null without a description or any url", () => { + expect(mapCarbonAd({ ...base, description: "" })).toBeNull() + expect(mapCarbonAd({ ...base, link: "", statlink: "" })).toBeNull() + }) + it("truncates long copy", () => { + expect( + must(mapCarbonAd({ ...base, description: "x".repeat(300) })).headline.length + ).toBeLessThanOrEqual(140) + }) + it("rejects non-http targets", () => { + expect(mapCarbonAd({ ...base, link: "javascript:alert(1)", statlink: "" })).toBeNull() + }) +}) + +describe("house ads", () => { + it("rotates and wraps", () => { + expect(HOUSE_ADS.length).toBeGreaterThanOrEqual(6) + expect(houseAd(0).adId).toBe(must(HOUSE_ADS[0]).adId) + expect(houseAd(HOUSE_ADS.length).adId).toBe(must(HOUSE_ADS[0]).adId) + for (const a of HOUSE_ADS) { + expect(a.source).toBe("house") + expect(a.targetUrl.startsWith("https://")).toBe(true) + } + }) +}) + +describe("displayLabel", () => { + it("shows the bare host", () => { + expect(displayLabel("https://www.sentry.io/welcome?x=1", "Sentry")).toBe("sentry.io") + }) + it("falls back to the advertiser when the url is a tracker or junk", () => { + expect(displayLabel("https://srv.carbonads.net/ads/click/x/abc", "IONOS")).toBe("IONOS") + expect(displayLabel("not a url", "Acme")).toBe("Acme") + }) +}) diff --git a/packages/api/src/config/env.ts b/packages/api/src/config/env.ts index e94f5b26..c85fcb88 100644 --- a/packages/api/src/config/env.ts +++ b/packages/api/src/config/env.ts @@ -83,6 +83,18 @@ export const env = { undefined ) }, + // carbon ads — empty zone key means the sdk's demo zone + get carbonZoneKey(): string { + return optionalEnv("CARBON_ZONE_KEY", "") + }, + get carbonPlacement(): string { + return optionalEnv("CARBON_PLACEMENT", "distrotv") + }, + // estimated usd cpm — carbon returns no price + get adCpmRate(): number { + const v = Number(optionalEnv("AD_CPM_RATE", "10")) + return Number.isFinite(v) && v > 0 ? v : 10 + }, get allowedOrigins(): string[] { const origins = requireEnv("ALLOWED_ORIGINS") .split(",") diff --git a/packages/api/src/db/migrations/0024_chemical_enchantress.sql b/packages/api/src/db/migrations/0024_chemical_enchantress.sql new file mode 100644 index 00000000..d1b40a3c --- /dev/null +++ b/packages/api/src/db/migrations/0024_chemical_enchantress.sql @@ -0,0 +1,2 @@ +ALTER TABLE "ad_impressions" ADD COLUMN "click_beacon_url" text;--> statement-breakpoint +ALTER TABLE "ad_impressions" ADD COLUMN "view_beacon_url" text; \ No newline at end of file diff --git a/packages/api/src/db/migrations/meta/0024_snapshot.json b/packages/api/src/db/migrations/meta/0024_snapshot.json new file mode 100644 index 00000000..1eb88ff8 --- /dev/null +++ b/packages/api/src/db/migrations/meta/0024_snapshot.json @@ -0,0 +1,1622 @@ +{ + "id": "13a9703a-899a-459f-b176-301539db9db3", + "prevId": "84e8baf9-16b8-4b48-9734-3f32ac35446d", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "github_id": { + "name": "github_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "github_login": { + "name": "github_login", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "repos_count": { + "name": "repos_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "primary_language": { + "name": "primary_language", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "signed_up_at": { + "name": "signed_up_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_github_id_unique": { + "name": "users_github_id_unique", + "nullsNotDistinct": false, + "columns": ["github_id"] + }, + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.devices": { + "name": "devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "machine_id_hash": { + "name": "machine_id_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "device_name": { + "name": "device_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "os": { + "name": "os", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "ide_type": { + "name": "ide_type", + "type": "ide_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "cli_version": { + "name": "cli_version", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "device_secret_hash": { + "name": "device_secret_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_heartbeat": { + "name": "last_heartbeat", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "devices_user_machine_idx": { + "name": "devices_user_machine_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "machine_id_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "devices_user_id_users_id_fk": { + "name": "devices_user_id_users_id_fk", + "tableFrom": "devices", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.preferences": { + "name": "preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "blocked_categories": { + "name": "blocked_categories", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "enabled_surfaces": { + "name": "enabled_surfaces", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "max_per_hour": { + "name": "max_per_hour", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 9999 + }, + "max_per_day": { + "name": "max_per_day", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 99999 + }, + "quiet_hours_start": { + "name": "quiet_hours_start", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "quiet_hours_end": { + "name": "quiet_hours_end", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tz_offset_minutes": { + "name": "tz_offset_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "idle_sensitivity_ms": { + "name": "idle_sensitivity_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "session_warmup_ms": { + "name": "session_warmup_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "night_mode": { + "name": "night_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "channel_mode": { + "name": "channel_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'balanced'" + }, + "news_topics": { + "name": "news_topics", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "enabled_feeds": { + "name": "enabled_feeds", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{\"ads\"}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "preferences_user_id_users_id_fk": { + "name": "preferences_user_id_users_id_fk", + "tableFrom": "preferences", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "preferences_user_id_unique": { + "name": "preferences_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slot_impressions": { + "name": "slot_impressions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "news_id": { + "name": "news_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "opened_url": { + "name": "opened_url", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "saved": { + "name": "saved", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'news'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slot_impressions_user_id_idx": { + "name": "slot_impressions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slot_impressions_user_created_at_idx": { + "name": "slot_impressions_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slot_impressions_user_id_users_id_fk": { + "name": "slot_impressions_user_id_users_id_fk", + "tableFrom": "slot_impressions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "slot_impressions_device_id_devices_id_fk": { + "name": "slot_impressions_device_id_devices_id_fk", + "tableFrom": "slot_impressions", + "tableTo": "devices", + "columnsFrom": ["device_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reading_list_items": { + "name": "reading_list_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "news_id": { + "name": "news_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "headline": { + "name": "headline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "saved_at": { + "name": "saved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "reading_user_saved_idx": { + "name": "reading_user_saved_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "saved_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reading_user_news_unique": { + "name": "reading_user_news_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "news_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reading_list_items_user_id_users_id_fk": { + "name": "reading_list_items_user_id_users_id_fk", + "tableFrom": "reading_list_items", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channels": { + "name": "channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_on": { + "name": "default_on", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "channels_key_uq": { + "name": "channels_key_uq", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_subscriptions": { + "name": "channel_subscriptions", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "channel_subscriptions_user_idx": { + "name": "channel_subscriptions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "channel_subscriptions_user_id_users_id_fk": { + "name": "channel_subscriptions_user_id_users_id_fk", + "tableFrom": "channel_subscriptions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_subscriptions_channel_id_channels_id_fk": { + "name": "channel_subscriptions_channel_id_channels_id_fk", + "tableFrom": "channel_subscriptions", + "tableTo": "channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_subscriptions_user_id_channel_id_pk": { + "name": "channel_subscriptions_user_id_channel_id_pk", + "columns": ["user_id", "channel_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.news_sources": { + "name": "news_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "half_life_hours": { + "name": "half_life_hours", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 12 + }, + "fetch_interval_min": { + "name": "fetch_interval_min", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "healthy": { + "name": "healthy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_fetched_at": { + "name": "last_fetched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "news_sources_key_uq": { + "name": "news_sources_key_uq", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "news_sources_channel_id_channels_id_fk": { + "name": "news_sources_channel_id_channels_id_fk", + "tableFrom": "news_sources", + "tableTo": "channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.news_items": { + "name": "news_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "headline": { + "name": "headline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "comments_url": { + "name": "comments_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "comments_count": { + "name": "comments_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "fetched_at": { + "name": "fetched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "news_items_channel_published_idx": { + "name": "news_items_channel_published_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "published_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "news_items_published_idx": { + "name": "news_items_published_idx", + "columns": [ + { + "expression": "published_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "news_items_channel_id_channels_id_fk": { + "name": "news_items_channel_id_channels_id_fk", + "tableFrom": "news_items", + "tableTo": "channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "news_items_source_id_news_sources_id_fk": { + "name": "news_items_source_id_news_sources_id_fk", + "tableFrom": "news_items", + "tableTo": "news_sources", + "columnsFrom": ["source_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.watchlists": { + "name": "watchlists", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "watchlists_user_name_uq": { + "name": "watchlists_user_name_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "watchlists_user_idx": { + "name": "watchlists_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "watchlists_user_id_users_id_fk": { + "name": "watchlists_user_id_users_id_fk", + "tableFrom": "watchlists", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.watchlist_tickers": { + "name": "watchlist_tickers", + "schema": "", + "columns": { + "watchlist_id": { + "name": "watchlist_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "symbol": { + "name": "symbol", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "asset_class": { + "name": "asset_class", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "watchlist_tickers_symbol_idx": { + "name": "watchlist_tickers_symbol_idx", + "columns": [ + { + "expression": "symbol", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "watchlist_tickers_watchlist_id_watchlists_id_fk": { + "name": "watchlist_tickers_watchlist_id_watchlists_id_fk", + "tableFrom": "watchlist_tickers", + "tableTo": "watchlists", + "columnsFrom": ["watchlist_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "watchlist_tickers_watchlist_id_symbol_pk": { + "name": "watchlist_tickers_watchlist_id_symbol_pk", + "columns": ["watchlist_id", "symbol"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.alerts": { + "name": "alerts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "symbol": { + "name": "symbol", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "threshold_pct": { + "name": "threshold_pct", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "alerts_user_symbol_uq": { + "name": "alerts_user_symbol_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "symbol", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"alerts\".\"symbol\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "alerts_user_global_uq": { + "name": "alerts_user_global_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"alerts\".\"symbol\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "alerts_user_idx": { + "name": "alerts_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "alerts_user_id_users_id_fk": { + "name": "alerts_user_id_users_id_fk", + "tableFrom": "alerts", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.alert_events": { + "name": "alert_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "symbol": { + "name": "symbol", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "change_pct": { + "name": "change_pct", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "threshold_pct": { + "name": "threshold_pct", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "fired_at": { + "name": "fired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "alert_events_device_symbol_fired_idx": { + "name": "alert_events_device_symbol_fired_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "symbol", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"fired_at\" desc", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_events_user_fired_idx": { + "name": "alert_events_user_fired_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"fired_at\" desc", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "alert_events_user_id_users_id_fk": { + "name": "alert_events_user_id_users_id_fk", + "tableFrom": "alert_events", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "alert_events_device_id_devices_id_fk": { + "name": "alert_events_device_id_devices_id_fk", + "tableFrom": "alert_events", + "tableTo": "devices", + "columnsFrom": ["device_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ad_impressions": { + "name": "ad_impressions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "delivery_id": { + "name": "delivery_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ad_id": { + "name": "ad_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "advertiser": { + "name": "advertiser", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "headline": { + "name": "headline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_url": { + "name": "target_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "click_beacon_url": { + "name": "click_beacon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "view_beacon_url": { + "name": "view_beacon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "clicked": { + "name": "clicked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "clicked_at": { + "name": "clicked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cpm_rate": { + "name": "cpm_rate", + "type": "numeric(12, 6)", + "primaryKey": false, + "notNull": true + }, + "earned_amount": { + "name": "earned_amount", + "type": "numeric(12, 6)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ad_impressions_delivery_uq": { + "name": "ad_impressions_delivery_uq", + "columns": [ + { + "expression": "delivery_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ad_impressions_user_created_idx": { + "name": "ad_impressions_user_created_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ad_impressions_user_id_users_id_fk": { + "name": "ad_impressions_user_id_users_id_fk", + "tableFrom": "ad_impressions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ad_impressions_device_id_devices_id_fk": { + "name": "ad_impressions_device_id_devices_id_fk", + "tableFrom": "ad_impressions", + "tableTo": "devices", + "columnsFrom": ["device_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.ide_type": { + "name": "ide_type", + "schema": "public", + "values": ["terminal", "vscode", "cursor"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/api/src/db/migrations/meta/_journal.json b/packages/api/src/db/migrations/meta/_journal.json index 86df5ace..b1d66efa 100644 --- a/packages/api/src/db/migrations/meta/_journal.json +++ b/packages/api/src/db/migrations/meta/_journal.json @@ -169,6 +169,13 @@ "when": 1789841152669, "tag": "0023_steep_shockwave", "breakpoints": true + }, + { + "idx": 24, + "version": "7", + "when": 1789841196558, + "tag": "0024_chemical_enchantress", + "breakpoints": true } ] } diff --git a/packages/api/src/db/schema/ad_impressions.ts b/packages/api/src/db/schema/ad_impressions.ts index 496469e9..be1bd5af 100644 --- a/packages/api/src/db/schema/ad_impressions.ts +++ b/packages/api/src/db/schema/ad_impressions.ts @@ -12,8 +12,10 @@ import { import { users } from "./users.js" import { devices } from "./devices.js" -// one row per served ad that was seen or clicked. seed of the exchange ledger: -// slot, audience (user/device), impression, click, price. +// one row per served ad — it is also the delivery record: inserted as "pending" +// when the ad is handed to a device, then filled in by /ingest (view) and +// /ads/click (click). seed of the exchange ledger: slot, audience, impression, +// click, price. lives in postgres (not redis) so deliveries survive restarts. export const adImpressions = pgTable( "ad_impressions", { @@ -30,8 +32,11 @@ export const adImpressions = pgTable( advertiser: text("advertiser").notNull(), headline: text("headline").notNull(), targetUrl: text("target_url").notNull(), + // network tracking urls (carbon statlink / statview); null for house ads + clickBeaconUrl: text("click_beacon_url"), + viewBeaconUrl: text("view_beacon_url"), durationMs: integer("duration_ms").notNull().default(0), - // ImpressionResult, or "pending" when a click arrived before the impression synced + // "pending" = served, not yet reported as seen; else an ImpressionResult result: text("result").notNull().default("pending"), clicked: boolean("clicked").notNull().default(false), clickedAt: timestamp("clicked_at", { withTimezone: true }), diff --git a/packages/api/src/services/ad-supply.service.ts b/packages/api/src/services/ad-supply.service.ts new file mode 100644 index 00000000..a1335f02 --- /dev/null +++ b/packages/api/src/services/ad-supply.service.ts @@ -0,0 +1,63 @@ +import { randomUUID } from "node:crypto" +import type { SponsoredPayload } from "@distrotv/shared" +import { env } from "../config/env.js" +import { getDb } from "../db/index.js" +import { adImpressions } from "../db/schema/ad_impressions.js" +import { logger } from "../lib/logger.js" +import { displayLabel, fetchCarbonAd } from "./carbon-ad.provider.js" +import { houseAd } from "./house-ads.js" + +let houseCursor = 0 + +// picks n ads and records each as a "pending" delivery row. never throws — ad +// supply must not fail content serving; on any error the caller just gets no ads. +export async function nextAds(args: { + userId: string + deviceId: string + n: number +}): Promise { + if (args.n <= 0) return [] + try { + const carbon = await fetchCarbonAd() + const cpmRate = env.adCpmRate + const picked = Array.from({ length: args.n }, (_, i) => ({ + // carbon fills the first slot of a batch; house ads give the rest variety + ad: i === 0 && carbon ? carbon : houseAd(houseCursor++), + deliveryId: randomUUID(), + })) + + await getDb() + .insert(adImpressions) + .values( + picked.map(({ ad, deliveryId }) => ({ + userId: args.userId, + deviceId: args.deviceId, + deliveryId, + adId: ad.adId, + source: ad.source, + advertiser: ad.advertiser, + headline: ad.headline, + targetUrl: ad.targetUrl, + clickBeaconUrl: ad.clickBeaconUrl, + viewBeaconUrl: ad.viewBeaconUrl, + cpmRate: String(cpmRate), + })) + ) + + return picked.map(({ ad, deliveryId }) => ({ + kind: "sponsored", + adId: ad.adId, + source: ad.source, + advertiser: ad.advertiser, + headline: ad.headline, + ctaText: ad.ctaText, + displayUrl: displayLabel(ad.targetUrl, ad.advertiser), + clickUrl: `${env.apiUrl}/ads/click/${deliveryId}`, + deliveryId, + cpmRate, + })) + } catch (err) { + logger.warn({ err }, "ad supply failed") + return [] + } +} diff --git a/packages/api/src/services/carbon-ad.provider.ts b/packages/api/src/services/carbon-ad.provider.ts new file mode 100644 index 00000000..4cecb3e5 --- /dev/null +++ b/packages/api/src/services/carbon-ad.provider.ts @@ -0,0 +1,101 @@ +import { createHash } from "node:crypto" +import { fetchAd } from "@carbonads/sdk" +import { env } from "../config/env.js" +import { logger } from "../lib/logger.js" + +export interface NormalizedAd { + adId: string + source: "carbon" | "house" + advertiser: string + headline: string + ctaText: string + targetUrl: string + // network tracking urls; null when there is nothing extra to fire + clickBeaconUrl: string | null + viewBeaconUrl: string | null +} + +export interface CarbonAdLike { + company?: string + companyTagline?: string + description?: string + link?: string + statlink?: string + statviewUrl?: string + callToAction?: string +} + +const FETCH_TIMEOUT_MS = 3_000 +const CACHE_TTL_MS = 60_000 +const TRACKER_HOSTS = ["carbonads.net", "buysellads.com", "buysellads.net"] + +function truncate(s: string, max: number): string { + return s.length <= max ? s : s.slice(0, max - 1) + "…" +} + +function httpUrl(u: string | undefined): string | null { + if (!u) return null + try { + const p = new URL(u.startsWith("//") ? `https:${u}` : u) + return p.protocol === "https:" || p.protocol === "http:" ? p.toString() : null + } catch { + return null + } +} + +// short link label: bare host, or the advertiser name when the url is an ad-network tracker +export function displayLabel(url: string, advertiser: string): string { + try { + const host = new URL(url).hostname.replace(/^www\./, "") + if (!host || TRACKER_HOSTS.some((t) => host === t || host.endsWith(`.${t}`))) return advertiser + return host + } catch { + return advertiser + } +} + +export function mapCarbonAd(ad: CarbonAdLike): NormalizedAd | null { + if (!ad.description) return null + const statlink = httpUrl(ad.statlink) + const targetUrl = httpUrl(ad.link) ?? statlink + if (!targetUrl) return null + const hash = createHash("sha256") + .update(`${ad.company ?? ""}:${ad.description}:${ad.statlink ?? ""}`) + .digest("hex") + .slice(0, 32) + return { + adId: `carbon:${hash}`, + source: "carbon", + advertiser: truncate(ad.company || ad.companyTagline || "Sponsored", 60), + headline: truncate(ad.description, 140), + ctaText: truncate(ad.callToAction || "Learn more", 30), + targetUrl, + // when the landing link already is the statlink, the redirect itself counts the click + clickBeaconUrl: statlink && statlink !== targetUrl ? statlink : null, + viewBeaconUrl: httpUrl(ad.statviewUrl), + } +} + +let cached: { at: number; ad: NormalizedAd | null } | null = null + +// never throws. 60s in-process cache so /me/content/next doesn't hit carbon per request. +export async function fetchCarbonAd(): Promise { + if (cached && Date.now() - cached.at < CACHE_TTL_MS) return cached.ad + try { + const opts: { placement: string; serve?: string } = { placement: env.carbonPlacement } + if (env.carbonZoneKey) opts.serve = env.carbonZoneKey + const raw = await Promise.race([ + fetchAd(opts), + new Promise((_, reject) => + setTimeout(() => reject(new Error("carbon_fetch_timeout")), FETCH_TIMEOUT_MS) + ), + ]) + const ad = raw ? mapCarbonAd(raw as CarbonAdLike) : null + cached = { at: Date.now(), ad } + return ad + } catch (err) { + logger.warn({ err }, "carbon ad fetch failed") + cached = { at: Date.now(), ad: null } + return null + } +} diff --git a/packages/api/src/services/house-ads.ts b/packages/api/src/services/house-ads.ts new file mode 100644 index 00000000..21a75dfe --- /dev/null +++ b/packages/api/src/services/house-ads.ts @@ -0,0 +1,86 @@ +import type { NormalizedAd } from "./carbon-ad.provider.js" + +function h( + slug: string, + advertiser: string, + headline: string, + ctaText: string, + targetUrl: string +): NormalizedAd { + return { + adId: `house:${slug}`, + source: "house", + advertiser, + headline, + ctaText, + targetUrl, + clickBeaconUrl: null, + viewBeaconUrl: null, + } +} + +// demo creatives shown when carbon has no fill. not sold inventory. +export const HOUSE_ADS: NormalizedAd[] = [ + h( + "distro-advertise", + "Distro TV", + "Your ad here. Reach developers while their agent works.", + "Advertise", + "https://distrotv.xyz/advertisers" + ), + h( + "railway", + "Railway", + "Ship your app in minutes. Infrastructure that gets out of the way.", + "Deploy now", + "https://railway.com" + ), + h( + "neon", + "Neon", + "Serverless Postgres with branching. A database for every pull request.", + "Start free", + "https://neon.tech" + ), + h( + "sentry", + "Sentry", + "See the error, the commit, and the fix. Monitoring built for developers.", + "Try Sentry", + "https://sentry.io" + ), + h( + "posthog", + "PostHog", + "Product analytics, session replay and feature flags in one place.", + "Get started", + "https://posthog.com" + ), + h( + "vercel", + "Vercel", + "Build and deploy the web. Preview every change before it ships.", + "Deploy", + "https://vercel.com" + ), + h( + "linear", + "Linear", + "Plan and build products. Issue tracking your team will actually use.", + "Try Linear", + "https://linear.app" + ), + h( + "supabase", + "Supabase", + "Postgres, auth, storage and realtime. Build in a weekend, scale to millions.", + "Start project", + "https://supabase.com" + ), +] + +export function houseAd(index: number): NormalizedAd { + const ad = HOUSE_ADS[((index % HOUSE_ADS.length) + HOUSE_ADS.length) % HOUSE_ADS.length] + if (!ad) throw new Error("house_ads_empty") + return ad +} From ecf75cc83f11028450d288fbbdbe3409eaa98f53 Mon Sep 17 00:00:00 2001 From: distroinfinity Date: Sat, 19 Sep 2026 11:09:09 -0700 Subject: [PATCH 05/35] feat(api): serve sponsored slots by enabled feeds --- packages/api/src/__tests__/ad-mix.test.ts | 61 ++++++++++++++++++ packages/api/src/routes/me-content.ts | 63 ++++++++++++------- packages/api/src/routes/me-preferences.ts | 1 + packages/api/src/services/ad-mix.ts | 49 +++++++++++++++ .../src/validators/preferences.validators.ts | 10 ++- 5 files changed, 159 insertions(+), 25 deletions(-) create mode 100644 packages/api/src/__tests__/ad-mix.test.ts create mode 100644 packages/api/src/services/ad-mix.ts diff --git a/packages/api/src/__tests__/ad-mix.test.ts b/packages/api/src/__tests__/ad-mix.test.ts new file mode 100644 index 00000000..5c48bdb1 --- /dev/null +++ b/packages/api/src/__tests__/ad-mix.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from "vitest" +import { ChannelMode } from "@distrotv/shared" +import { contentPlan, splitCounts, mixSlots, normalizeFeeds } from "../services/ad-mix.js" +import { validateUpdatePreferences } from "../validators/preferences.validators.js" + +describe("contentPlan", () => { + it("no content feeds → none", () => + expect(contentPlan(["ads"], ChannelMode.Balanced)).toBe("none")) + it("news only", () => expect(contentPlan(["ads", "news"], ChannelMode.TickerOnly)).toBe("news")) + it("markets only", () => expect(contentPlan(["markets"], ChannelMode.NewsOnly)).toBe("ticker")) + it("both → channel mode decides", () => { + expect(contentPlan(["news", "markets"], ChannelMode.NewsOnly)).toBe("news") + expect(contentPlan(["news", "markets"], ChannelMode.TickerOnly)).toBe("ticker") + expect(contentPlan(["news", "markets"], ChannelMode.Balanced)).toBe("mix") + }) +}) + +describe("splitCounts", () => { + it("ads only → all ads", () => + expect(splitCounts(6, true, false)).toEqual({ ads: 6, content: 0 })) + it("ads + content → half, ads round up", () => + expect(splitCounts(5, true, true)).toEqual({ ads: 3, content: 2 })) + it("ads off → all content", () => + expect(splitCounts(6, false, true)).toEqual({ ads: 0, content: 6 })) + it("nothing on → nothing", () => + expect(splitCounts(6, false, false)).toEqual({ ads: 0, content: 0 })) +}) + +describe("mixSlots", () => { + it("alternates starting with an ad", () => { + expect(mixSlots(["a1", "a2"], ["c1", "c2"])).toEqual(["a1", "c1", "a2", "c2"]) + }) + it("drains the longer list", () => { + expect(mixSlots(["a1"], ["c1", "c2", "c3"])).toEqual(["a1", "c1", "c2", "c3"]) + expect(mixSlots(["a1", "a2"], [])).toEqual(["a1", "a2"]) + }) +}) + +describe("normalizeFeeds", () => { + it("defaults to ads when unset or junk", () => { + expect(normalizeFeeds(undefined)).toEqual(["ads"]) + expect(normalizeFeeds("x")).toEqual(["ads"]) + }) + it("keeps an explicit empty list and drops unknown values", () => { + expect(normalizeFeeds([])).toEqual([]) + expect(normalizeFeeds(["news", "bogus", "news"])).toEqual(["news"]) + }) +}) + +describe("preferences validator", () => { + it("accepts enabledFeeds", () => { + expect(validateUpdatePreferences({ enabledFeeds: ["ads", "news"] }).enabledFeeds).toEqual([ + "ads", + "news", + ]) + expect(validateUpdatePreferences({ enabledFeeds: [] }).enabledFeeds).toEqual([]) + }) + it("rejects unknown feeds", () => { + expect(() => validateUpdatePreferences({ enabledFeeds: ["crypto"] })).toThrow() + }) +}) diff --git a/packages/api/src/routes/me-content.ts b/packages/api/src/routes/me-content.ts index c560bddb..e3997511 100644 --- a/packages/api/src/routes/me-content.ts +++ b/packages/api/src/routes/me-content.ts @@ -1,22 +1,29 @@ import { Router } from "express" import { eq } from "drizzle-orm" import { ChannelMode } from "@distrotv/shared" -import type { NewsPayload, SlotPayload, TickerPayload } from "@distrotv/shared" +import type { Feed, NewsPayload, SlotPayload, TickerPayload } from "@distrotv/shared" import { getDb } from "../db/index.js" import { preferences } from "../db/schema/preferences.js" import { nextPicksForDevice } from "../services/news-selection.service.js" import { nextTickerForDevice } from "../services/ticker-selection.service.js" import { touchDeviceHeartbeat } from "../services/device-heartbeat.service.js" +import { + contentPlan, + mixSlots, + normalizeFeeds, + splitCounts, + type ContentPlan, +} from "../services/ad-mix.js" +import { nextAds } from "../services/ad-supply.service.js" export const meContentRouter: ReturnType = Router() // GET /me/content/next?n=N&deviceId=...&surface=terminal-tv -// returns { items: SlotPayload[] } based on user's channelMode. -// news_only → 100% news items -// ticker_only → 100% ticker items -// news_heavy → mix-ish (3:1 news:ticker; deterministic pattern deferred to Task 4) -// balanced → mix-ish (1:1 news:ticker; deterministic pattern deferred to Task 4) -// ticker_heavy → mix-ish (1:3 news:ticker; deterministic pattern deferred to Task 4) +// returns { items: SlotPayload[] } from the user's enabled feeds. +// ads only (default) → all sponsored +// ads + news/markets → alternate ad / content, starting with an ad +// ads off → content only (news / ticker / interleaved per channelMode) +// nothing enabled → [] meContentRouter.get("/next", async (req, res, next) => { try { const userId = res.locals["userId"] as string @@ -34,35 +41,43 @@ meContentRouter.get("/next", async (req, res, next) => { const n = Math.max(1, Math.min(Number.isFinite(nRaw) ? nRaw : 5, 20)) void (req.query["surface"] as string | undefined) // M5 may use this - const mode = await getMode(userId) - let items: SlotPayload[] - if (mode === ChannelMode.NewsOnly) { - items = await nextPicksForDevice({ userId, deviceId, n }) - } else if (mode === ChannelMode.TickerOnly) { - items = await onlyTicker(userId, deviceId, n) - } else { - // news_heavy | balanced | ticker_heavy — all treated as interleaved for now. - // Task 4 (slot-cache.ts) will refine per-device ratio using a counter. - items = await interleave(userId, deviceId, n) - } + const { mode, feeds } = await getModeAndFeeds(userId) + const plan = contentPlan(feeds, mode) + const counts = splitCounts(n, feeds.includes("ads"), plan !== "none") - res.json({ items }) + const [ads, content] = await Promise.all([ + nextAds({ userId, deviceId, n: counts.ads }), + fetchContent(plan, userId, deviceId, counts.content), + ]) + res.json({ items: mixSlots(ads, content) }) } catch (err) { next(err) } }) -async function getMode(userId: string): Promise { +async function fetchContent( + plan: ContentPlan, + userId: string, + deviceId: string, + n: number +): Promise { + if (n <= 0 || plan === "none") return [] + if (plan === "news") return nextPicksForDevice({ userId, deviceId, n }) + if (plan === "ticker") return onlyTicker(userId, deviceId, n) + return interleave(userId, deviceId, n) +} + +async function getModeAndFeeds(userId: string): Promise<{ mode: ChannelMode; feeds: Feed[] }> { const db = getDb() const [row] = await db - .select({ mode: preferences.channelMode }) + .select({ mode: preferences.channelMode, feeds: preferences.enabledFeeds }) .from(preferences) .where(eq(preferences.userId, userId)) .limit(1) - const m = row?.mode as string | undefined const valid = Object.values(ChannelMode) as string[] - if (m && valid.includes(m)) return m as ChannelMode - return ChannelMode.Balanced + const mode = + row?.mode && valid.includes(row.mode) ? (row.mode as ChannelMode) : ChannelMode.Balanced + return { mode, feeds: normalizeFeeds(row?.feeds) } } // fetch up to `n` tickers, skipping rotation indices that return null (missing quote diff --git a/packages/api/src/routes/me-preferences.ts b/packages/api/src/routes/me-preferences.ts index 143a245b..5ddcdfe0 100644 --- a/packages/api/src/routes/me-preferences.ts +++ b/packages/api/src/routes/me-preferences.ts @@ -22,6 +22,7 @@ function shape(row: PreferencesRow) { nightMode: row.nightMode, channelMode: row.channelMode, newsTopics: row.newsTopics, + enabledFeeds: row.enabledFeeds, updatedAt: row.updatedAt.toISOString(), } } diff --git a/packages/api/src/services/ad-mix.ts b/packages/api/src/services/ad-mix.ts new file mode 100644 index 00000000..16935952 --- /dev/null +++ b/packages/api/src/services/ad-mix.ts @@ -0,0 +1,49 @@ +import { ChannelMode, DEFAULT_FEEDS, FEEDS, type Feed } from "@distrotv/shared" + +export type ContentPlan = "none" | "news" | "ticker" | "mix" + +export function normalizeFeeds(raw: unknown): Feed[] { + if (!Array.isArray(raw)) return [...DEFAULT_FEEDS] + const out: Feed[] = [] + for (const v of raw) { + if (FEEDS.includes(v as Feed) && !out.includes(v as Feed)) out.push(v as Feed) + } + return out +} + +export function contentPlan(feeds: Feed[], mode: ChannelMode): ContentPlan { + const news = feeds.includes("news") + const markets = feeds.includes("markets") + if (!news && !markets) return "none" + if (news && !markets) return "news" + if (!news && markets) return "ticker" + if (mode === ChannelMode.NewsOnly) return "news" + if (mode === ChannelMode.TickerOnly) return "ticker" + return "mix" +} + +export function splitCounts( + n: number, + adsOn: boolean, + hasContent: boolean +): { ads: number; content: number } { + if (!adsOn && !hasContent) return { ads: 0, content: 0 } + if (adsOn && !hasContent) return { ads: n, content: 0 } + if (!adsOn) return { ads: 0, content: n } + const ads = Math.ceil(n / 2) + return { ads, content: n - ads } +} + +// alternate, starting with an ad (AD_SLOT_EVERY_N = 2); drain whichever list is longer. +export function mixSlots(ads: T[], content: T[]): T[] { + const out: T[] = [] + const a = [...ads] + const c = [...content] + while (a.length > 0 || c.length > 0) { + const ad = a.shift() + if (ad !== undefined) out.push(ad) + const item = c.shift() + if (item !== undefined) out.push(item) + } + return out +} diff --git a/packages/api/src/validators/preferences.validators.ts b/packages/api/src/validators/preferences.validators.ts index b0cb5ae6..aac9ed3a 100644 --- a/packages/api/src/validators/preferences.validators.ts +++ b/packages/api/src/validators/preferences.validators.ts @@ -1,4 +1,4 @@ -import { AdCategory, ChannelMode, NewsTopic } from "@distrotv/shared" +import { AdCategory, ChannelMode, FEEDS, NewsTopic, type Feed } from "@distrotv/shared" import { ValidationError } from "../errors/index.js" import { requireBody, validateEnumArray, validateEnumValue } from "./common.js" @@ -18,6 +18,7 @@ export interface UpdatePreferencesInput { nightMode?: boolean channelMode?: ChannelMode newsTopics?: NewsTopic[] + enabledFeeds?: Feed[] } const ALLOWED_KEYS = new Set([ @@ -32,6 +33,7 @@ const ALLOWED_KEYS = new Set([ "nightMode", "channelMode", "newsTopics", + "enabledFeeds", ]) export function validateUpdatePreferences(body: unknown): UpdatePreferencesInput { @@ -138,6 +140,12 @@ export function validateUpdatePreferences(body: unknown): UpdatePreferencesInput out.newsTopics = topics as NewsTopic[] } + if (b["enabledFeeds"] !== undefined) { + // an empty list is valid — the user turned every feed off + const feeds = validateEnumArray(b["enabledFeeds"], FEEDS as string[], "enabled_feeds") + out.enabledFeeds = [...new Set(feeds)] as Feed[] + } + return out } From 64dd67d20f8452b35b2f1426aba3ced1a09cccd9 Mon Sep 17 00:00:00 2001 From: distroinfinity Date: Sat, 19 Sep 2026 11:14:43 -0700 Subject: [PATCH 06/35] feat(api): record ad impressions and click redirect --- .../api/src/__tests__/ad-impression.test.ts | 37 ++++++ packages/api/src/app.ts | 3 + packages/api/src/routes/ads-click.ts | 19 +++ packages/api/src/routes/ingest.ts | 34 ++++- .../api/src/services/ad-impression.service.ts | 116 ++++++++++++++++++ 5 files changed, 207 insertions(+), 2 deletions(-) create mode 100644 packages/api/src/__tests__/ad-impression.test.ts create mode 100644 packages/api/src/routes/ads-click.ts create mode 100644 packages/api/src/services/ad-impression.service.ts diff --git a/packages/api/src/__tests__/ad-impression.test.ts b/packages/api/src/__tests__/ad-impression.test.ts new file mode 100644 index 00000000..3c5da9c1 --- /dev/null +++ b/packages/api/src/__tests__/ad-impression.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from "vitest" +import { computeEarned, parseAdImpression } from "../services/ad-impression.service.js" + +describe("computeEarned", () => { + it("pays cpm/1000 * 0.7 for a viewable impression", () => { + expect(computeEarned(12_000, "completed", 10)).toBeCloseTo(0.007, 6) + expect(computeEarned(1_000, "interrupted", 10)).toBeCloseTo(0.007, 6) + }) + it("pays nothing under one second", () => expect(computeEarned(999, "completed", 10)).toBe(0)) + it("pays nothing when skipped", () => expect(computeEarned(5_000, "skipped", 10)).toBe(0)) + it("pays nothing for junk input", () => { + expect(computeEarned(Number.NaN, "completed", 10)).toBe(0) + expect(computeEarned(5_000, "completed", -1)).toBe(0) + }) +}) + +describe("parseAdImpression", () => { + const ok = { + deliveryToken: "3f0c2f0e-7f1a-4b57-9d9e-0d8f4f3f2a11", + durationMs: 12000, + result: "completed", + } + it("accepts a well-formed row", () => expect(parseAdImpression(ok)).toEqual(ok)) + it("ignores extra client fields", () => + expect(parseAdImpression({ ...ok, deviceId: "x" })).toEqual(ok)) + it("rejects bad rows", () => { + expect(parseAdImpression({ ...ok, deliveryToken: 5 })).toBeNull() + expect(parseAdImpression({ ...ok, deliveryToken: "not-a-uuid" })).toBeNull() + expect(parseAdImpression({ ...ok, result: "won" })).toBeNull() + expect(parseAdImpression({ ...ok, result: "pending" })).toBeNull() + expect(parseAdImpression({ ...ok, durationMs: -1 })).toBeNull() + expect(parseAdImpression(null)).toBeNull() + }) + it("clamps absurd durations", () => { + expect(parseAdImpression({ ...ok, durationMs: 9_999_999 })?.durationMs).toBe(60_000) + }) +}) diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts index b5ae005a..1cf6326b 100644 --- a/packages/api/src/app.ts +++ b/packages/api/src/app.ts @@ -33,6 +33,7 @@ import { adminRouter } from "./routes/admin.js" import { testHelpersRouter } from "./routes/__test-helpers.js" import { requireAuth } from "./middleware/auth.js" import { globalLimiter, userLimiter } from "./middleware/rate-limit.js" +import { adsClickRouter } from "./routes/ads-click.js" import { getDb } from "./db/index.js" import { users } from "./db/schema/users.js" @@ -71,6 +72,8 @@ if (env.nodeEnv !== "production") { app.use(globalLimiter) app.use("/channels", channelsPublicRouter) +// public ad click redirect (global limiter applies) +app.use("/ads", adsClickRouter) app.use("/tickers", tickersRouter) app.use("/cli", cliVersionRouter) diff --git a/packages/api/src/routes/ads-click.ts b/packages/api/src/routes/ads-click.ts new file mode 100644 index 00000000..860139e4 --- /dev/null +++ b/packages/api/src/routes/ads-click.ts @@ -0,0 +1,19 @@ +import { Router } from "express" +import { env } from "../config/env.js" +import { logger } from "../lib/logger.js" +import { recordAdClick } from "../services/ad-impression.service.js" + +export const adsClickRouter: ReturnType = Router() + +// GET /ads/click/:deliveryId — public. the redirect target comes only from the +// server-side delivery row, never from the request. +adsClickRouter.get("/click/:deliveryId", async (req, res) => { + let target: string | null = null + try { + target = await recordAdClick(String(req.params["deliveryId"] ?? "")) + } catch (err) { + logger.warn({ err }, "ad click record failed") + } + res.setHeader("Cache-Control", "no-store") + res.redirect(302, target ?? env.webUrl) +}) diff --git a/packages/api/src/routes/ingest.ts b/packages/api/src/routes/ingest.ts index 09925312..3a7ee06b 100644 --- a/packages/api/src/routes/ingest.ts +++ b/packages/api/src/routes/ingest.ts @@ -2,6 +2,7 @@ import { Router } from "express" import type { ImpressionResult, NewsSource } from "@distrotv/shared" import { recordSlotImpression } from "../services/slot-impression.service.js" import { markServedOnImpression } from "../services/news-selection.service.js" +import { parseAdImpression, recordAdImpression } from "../services/ad-impression.service.js" import { logger } from "../lib/logger.js" interface RawNewsImpression { @@ -107,9 +108,38 @@ ingestRouter.post("/", async (req, res) => { } } - // return legacy empty arrays so CLI's applyResults loop doesn't throw on index access + // ad impressions: index-aligned results. the two error strings are in the cli's + // terminal set, so a bad row is tombstoned instead of retried forever. + const impressionResults: { ok: boolean; deliveryToken: string; error?: string }[] = [] + for (const raw of rawImpressions) { + const token = String((raw as { deliveryToken?: unknown } | null)?.deliveryToken ?? "") + const input = parseAdImpression(raw) + if (!input) { + impressionResults.push({ + ok: false, + deliveryToken: token, + error: "invalid_or_expired_delivery_token", + }) + continue + } + try { + const outcome = await recordAdImpression({ userId, input }) + impressionResults.push( + outcome === "ok" + ? { ok: true, deliveryToken: token } + : { ok: false, deliveryToken: token, error: outcome } + ) + } catch (err) { + logger.warn({ err }, "ingest: recordAdImpression failed") + // no error code → the cli treats it as transient and retries + impressionResults.push({ ok: false, deliveryToken: token }) + } + } + + // clicks are recorded by GET /ads/click/:deliveryId, not ingested; the array + // stays in the response so older clients' result loop keeps working. res.json({ - impressions: rawImpressions.map(() => ({ ok: false, deliveryToken: "" })), + impressions: impressionResults, clicks: rawClicks.map(() => ({ ok: false, deliveryToken: "" })), newsImpressions: newsImpressionResults, }) diff --git a/packages/api/src/services/ad-impression.service.ts b/packages/api/src/services/ad-impression.service.ts new file mode 100644 index 00000000..73823d2d --- /dev/null +++ b/packages/api/src/services/ad-impression.service.ts @@ -0,0 +1,116 @@ +import { and, eq, sql } from "drizzle-orm" +import { MIN_COMPLETED_DURATION_MS, REVENUE_SHARE_DEVELOPER } from "@distrotv/shared" +import { getDb } from "../db/index.js" +import { adImpressions } from "../db/schema/ad_impressions.js" +import { fireBeacon } from "../lib/beacon.js" + +const RESULTS = new Set(["completed", "skipped", "expired", "interrupted"]) +const MAX_DURATION_MS = 60_000 +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 interface AdImpressionInput { + deliveryToken: string + durationMs: number + result: string +} + +export function isDeliveryId(v: string): boolean { + return UUID_RE.test(v) +} + +export function parseAdImpression(raw: unknown): AdImpressionInput | null { + if (!raw || typeof raw !== "object") return null + const r = raw as Record + const token = r["deliveryToken"] + const duration = r["durationMs"] + const result = r["result"] + if ( + typeof token !== "string" || + !isDeliveryId(token) || + typeof duration !== "number" || + !Number.isFinite(duration) || + duration < 0 || + typeof result !== "string" || + !RESULTS.has(result) + ) { + return null + } + return { + deliveryToken: token, + durationMs: Math.min(Math.round(duration), MAX_DURATION_MS), + result, + } +} + +// estimated developer share for one impression. viewable = on screen >= 1s and not skipped. +export function computeEarned(durationMs: number, result: string, cpmRate: number): number { + if (!Number.isFinite(durationMs) || !Number.isFinite(cpmRate) || cpmRate <= 0) return 0 + if (result === "skipped" || durationMs < MIN_COMPLETED_DURATION_MS) return 0 + return (cpmRate / 1000) * REVENUE_SHARE_DEVELOPER +} + +export type AdIngestOutcome = "ok" | "invalid_or_expired_delivery_token" | "delivery_not_owned" + +// fills in the "pending" delivery row. a row that is already filled is left +// alone, so a retried batch is ok and never pays twice. +export async function recordAdImpression(args: { + userId: string + input: AdImpressionInput +}): Promise { + const db = getDb() + const [row] = await db + .select({ + userId: adImpressions.userId, + result: adImpressions.result, + cpmRate: adImpressions.cpmRate, + viewBeaconUrl: adImpressions.viewBeaconUrl, + }) + .from(adImpressions) + .where(eq(adImpressions.deliveryId, args.input.deliveryToken)) + .limit(1) + if (!row) return "invalid_or_expired_delivery_token" + if (row.userId !== args.userId) return "delivery_not_owned" + if (row.result !== "pending") return "ok" + + const earned = computeEarned(args.input.durationMs, args.input.result, Number(row.cpmRate)) + const updated = await db + .update(adImpressions) + .set({ + durationMs: args.input.durationMs, + result: args.input.result, + earnedAmount: earned.toFixed(6), + }) + .where( + and( + eq(adImpressions.deliveryId, args.input.deliveryToken), + eq(adImpressions.result, "pending") + ) + ) + .returning({ id: adImpressions.id }) + + // only the request that actually flipped the row fires the network's view beacon + if (updated.length > 0 && earned > 0 && row.viewBeaconUrl) void fireBeacon(row.viewBeaconUrl) + return "ok" +} + +// marks the delivery clicked and returns the advertiser url, or null when unknown. +export async function recordAdClick(deliveryId: string): Promise { + if (!isDeliveryId(deliveryId)) return null + const [before] = await getDb() + .select({ clicked: adImpressions.clicked }) + .from(adImpressions) + .where(eq(adImpressions.deliveryId, deliveryId)) + .limit(1) + if (!before) return null + const [row] = await getDb() + .update(adImpressions) + .set({ clicked: true, clickedAt: sql`coalesce(${adImpressions.clickedAt}, now())` }) + .where(eq(adImpressions.deliveryId, deliveryId)) + .returning({ + targetUrl: adImpressions.targetUrl, + clickBeaconUrl: adImpressions.clickBeaconUrl, + }) + if (!row) return null + if (!before.clicked && row.clickBeaconUrl) void fireBeacon(row.clickBeaconUrl) + return row.targetUrl +} From 9cd06f985347ba805fa53b8e8e27b3a0b2689af2 Mon Sep 17 00:00:00 2001 From: distroinfinity Date: Sat, 19 Sep 2026 11:15:38 -0700 Subject: [PATCH 07/35] feat(api): estimated earnings endpoints --- packages/api/src/__tests__/earnings.test.ts | 19 ++++ packages/api/src/app.ts | 2 + packages/api/src/routes/me-earnings.ts | 36 +++++++ packages/api/src/services/earnings.service.ts | 101 ++++++++++++++++++ 4 files changed, 158 insertions(+) create mode 100644 packages/api/src/__tests__/earnings.test.ts create mode 100644 packages/api/src/routes/me-earnings.ts create mode 100644 packages/api/src/services/earnings.service.ts diff --git a/packages/api/src/__tests__/earnings.test.ts b/packages/api/src/__tests__/earnings.test.ts new file mode 100644 index 00000000..100f55cd --- /dev/null +++ b/packages/api/src/__tests__/earnings.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from "vitest" +import { ctr, clampDays, clampLimit } from "../services/earnings.service.js" + +describe("earnings helpers", () => { + it("ctr is clicks / impressions, 0 when empty", () => { + expect(ctr(0, 0)).toBe(0) + expect(ctr(200, 5)).toBeCloseTo(0.025, 6) + }) + it("clamps days to 1..90, default 30", () => { + expect(clampDays(undefined)).toBe(30) + expect(clampDays("7")).toBe(7) + expect(clampDays("9999")).toBe(90) + expect(clampDays("abc")).toBe(30) + }) + it("clamps limit to 1..100, default 20", () => { + expect(clampLimit(undefined)).toBe(20) + expect(clampLimit("500")).toBe(100) + }) +}) diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts index 1cf6326b..d448f973 100644 --- a/packages/api/src/app.ts +++ b/packages/api/src/app.ts @@ -34,6 +34,7 @@ import { testHelpersRouter } from "./routes/__test-helpers.js" import { requireAuth } from "./middleware/auth.js" import { globalLimiter, userLimiter } from "./middleware/rate-limit.js" import { adsClickRouter } from "./routes/ads-click.js" +import { meEarningsRouter } from "./routes/me-earnings.js" import { getDb } from "./db/index.js" import { users } from "./db/schema/users.js" @@ -125,6 +126,7 @@ app.use("/me/alerts", requireAuth, userLimiter, meAlertsRouter) app.use("/me/activity-summary", requireAuth, userLimiter, meActivitySummaryRouter) app.use("/me/devices/:id/now", requireAuth, userLimiter, meNowPlayingRouter) app.use("/me/recent-news", requireAuth, userLimiter, meRecentNewsRouter) +app.use("/me/earnings", requireAuth, userLimiter, meEarningsRouter) app.use("/ingest", requireAuth, userLimiter, ingestRouter) app.use("/admin", adminRouter) diff --git a/packages/api/src/routes/me-earnings.ts b/packages/api/src/routes/me-earnings.ts new file mode 100644 index 00000000..eb7da30e --- /dev/null +++ b/packages/api/src/routes/me-earnings.ts @@ -0,0 +1,36 @@ +import { Router } from "express" +import { + clampDays, + clampLimit, + getRecent, + getSummary, + getTimeseries, +} from "../services/earnings.service.js" + +export const meEarningsRouter: ReturnType = Router() + +meEarningsRouter.get("/summary", async (_req, res, next) => { + try { + res.json(await getSummary(res.locals["userId"] as string)) + } catch (err) { + next(err) + } +}) + +meEarningsRouter.get("/timeseries", async (req, res, next) => { + try { + const days = clampDays(req.query["days"]) + res.json({ points: await getTimeseries(res.locals["userId"] as string, days) }) + } catch (err) { + next(err) + } +}) + +meEarningsRouter.get("/recent", async (req, res, next) => { + try { + const limit = clampLimit(req.query["limit"]) + res.json({ items: await getRecent(res.locals["userId"] as string, limit) }) + } catch (err) { + next(err) + } +}) diff --git a/packages/api/src/services/earnings.service.ts b/packages/api/src/services/earnings.service.ts new file mode 100644 index 00000000..c1fd68bb --- /dev/null +++ b/packages/api/src/services/earnings.service.ts @@ -0,0 +1,101 @@ +import { and, desc, eq, ne, or, sql } from "drizzle-orm" +import { REVENUE_SHARE_DEVELOPER } from "@distrotv/shared" +import { env } from "../config/env.js" +import { getDb } from "../db/index.js" +import { adImpressions as t } from "../db/schema/ad_impressions.js" + +export function ctr(impressions: number, clicks: number): number { + return impressions > 0 ? clicks / impressions : 0 +} + +function clampInt(raw: unknown, min: number, max: number, fallback: number): number { + const v = Number.parseInt(String(raw ?? ""), 10) + return Number.isFinite(v) ? Math.max(min, Math.min(max, v)) : fallback +} +export const clampDays = (raw: unknown): number => clampInt(raw, 1, 90, 30) +export const clampLimit = (raw: unknown): number => clampInt(raw, 1, 100, 20) + +function extractRows(result: unknown): T[] { + return ((result as { rows?: unknown[] }).rows ?? (result as unknown[])) as T[] +} + +// all money here is an estimate: configurable cpm x developer share. day buckets +// are UTC; the cli shows the local-day total. +export async function getSummary(userId: string) { + const [row] = await getDb() + .select({ + today: sql`coalesce(sum(${t.earnedAmount}) filter (where ${t.createdAt} >= date_trunc('day', now())), 0)`, + last7d: sql`coalesce(sum(${t.earnedAmount}) filter (where ${t.createdAt} >= now() - interval '7 days'), 0)`, + allTime: sql`coalesce(sum(${t.earnedAmount}), 0)`, + served: sql`count(*)::int`, + impressions: sql`(count(*) filter (where ${t.result} <> 'pending'))::int`, + paidImpressions: sql`(count(*) filter (where ${t.earnedAmount} > 0))::int`, + clicks: sql`(count(*) filter (where ${t.clicked}))::int`, + }) + .from(t) + .where(eq(t.userId, userId)) + const impressions = row?.impressions ?? 0 + const clicks = row?.clicks ?? 0 + return { + today: Number(row?.today ?? 0), + last7d: Number(row?.last7d ?? 0), + allTime: Number(row?.allTime ?? 0), + served: row?.served ?? 0, + impressions, + paidImpressions: row?.paidImpressions ?? 0, + clicks, + ctr: ctr(impressions, clicks), + cpmRate: env.adCpmRate, + revenueShare: REVENUE_SHARE_DEVELOPER, + estimated: true as const, + } +} + +export async function getTimeseries(userId: string, days: number) { + const raw = await getDb().execute(sql` + select to_char(d.day, 'YYYY-MM-DD') as date, + coalesce(sum(a.earned_amount), 0)::float8 as earned, + (count(a.id) filter (where a.result <> 'pending'))::int as impressions, + (count(a.id) filter (where a.clicked))::int as clicks + from generate_series( + date_trunc('day', now()) - (${days - 1} * interval '1 day'), + date_trunc('day', now()), + interval '1 day' + ) as d(day) + left join ad_impressions a + on a.user_id = ${userId} + and a.created_at >= d.day + and a.created_at < d.day + interval '1 day' + group by d.day + order by d.day + `) + return extractRows<{ date: string; earned: number; impressions: number; clicks: number }>( + raw + ).map((r) => ({ + date: r.date, + earned: Number(r.earned), + impressions: Number(r.impressions), + clicks: Number(r.clicks), + })) +} + +// ads the user actually saw or clicked — served-but-unseen deliveries stay out of the list +export async function getRecent(userId: string, limit: number) { + const rows = await getDb() + .select() + .from(t) + .where(and(eq(t.userId, userId), or(ne(t.result, "pending"), eq(t.clicked, true)))) + .orderBy(desc(t.createdAt)) + .limit(limit) + return rows.map((r) => ({ + id: r.id, + advertiser: r.advertiser, + headline: r.headline, + source: r.source, + durationMs: r.durationMs, + result: r.result, + clicked: r.clicked, + earned: Number(r.earnedAmount), + createdAt: r.createdAt.toISOString(), + })) +} From 853d7194b450624bfc3ee5ab24824d8a4a9c37ba Mon Sep 17 00:00:00 2001 From: distroinfinity Date: Sat, 19 Sep 2026 11:17:47 -0700 Subject: [PATCH 08/35] fix(api): bare ad click path redirects home --- packages/api/src/routes/ads-click.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/api/src/routes/ads-click.ts b/packages/api/src/routes/ads-click.ts index 860139e4..65298e82 100644 --- a/packages/api/src/routes/ads-click.ts +++ b/packages/api/src/routes/ads-click.ts @@ -17,3 +17,8 @@ adsClickRouter.get("/click/:deliveryId", async (req, res) => { res.setHeader("Cache-Control", "no-store") res.redirect(302, target ?? env.webUrl) }) + +// bare /ads/click (no id, e.g. a half-copied link) → home instead of a raw 404 +adsClickRouter.get("/click", (_req, res) => { + res.redirect(302, env.webUrl) +}) From ae2bde241c29a859a825fc22e6860dce819c22cd Mon Sep 17 00:00:00 2001 From: distroinfinity Date: Sat, 19 Sep 2026 11:21:30 -0700 Subject: [PATCH 09/35] feat(cli): ledger + sync ad impressions, open command --- packages/cli/src/commands/action.ts | 8 +++ packages/cli/src/index.ts | 10 +++- .../src/lib/__tests__/ad-impression.test.ts | 59 +++++++++++++++++++ packages/cli/src/lib/__tests__/ledger.test.ts | 15 +++++ packages/cli/src/lib/ad-impression.ts | 36 +++++++++++ packages/cli/src/lib/api-client.ts | 8 +-- packages/cli/src/lib/daemon/display.ts | 12 +++- packages/cli/src/lib/daemon/orchestrator.ts | 49 +++++++++++---- packages/cli/src/lib/daemon/state-machine.ts | 19 +++++- packages/cli/src/lib/daemon/sync.ts | 12 ++-- packages/cli/src/lib/ledger.ts | 14 +++-- packages/cli/src/lib/slot-cache.ts | 8 ++- 12 files changed, 215 insertions(+), 35 deletions(-) create mode 100644 packages/cli/src/lib/__tests__/ad-impression.test.ts create mode 100644 packages/cli/src/lib/ad-impression.ts diff --git a/packages/cli/src/commands/action.ts b/packages/cli/src/commands/action.ts index e5e7429c..815eed91 100644 --- a/packages/cli/src/commands/action.ts +++ b/packages/cli/src/commands/action.ts @@ -23,6 +23,14 @@ export const discoverCmd = new Command("discover") process.exit(await sendAction("discover")) }) +// `dtv open` — the everyday name for discover: opens whatever is on screen +// (a sponsor goes through the click redirect, so the click is recorded). +export const openCmd = new Command("open") + .description("open the sponsor or story currently on screen") + .action(async () => { + process.exit(await sendAction("discover")) + }) + export const skipCmd = new Command("skip") .description("advance to the next slot (fallback for [S])") .action(async () => { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index ad1ebc47..301858b8 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -15,7 +15,14 @@ import { uninstallCmd } from "./commands/uninstall.js" import { upgradeCmd } from "./commands/upgrade.js" import { hookCmd } from "./commands/hook.js" import { statuslineCmd } from "./commands/statusline.js" -import { chartCmd, discoverCmd, killSessionCmd, muteCmd, skipCmd } from "./commands/action.js" +import { + chartCmd, + discoverCmd, + killSessionCmd, + muteCmd, + openCmd, + skipCmd, +} from "./commands/action.js" import { preferencesCmd } from "./commands/preferences.js" import { watchlistCmd } from "./commands/watchlist.js" import { feedbackCmd } from "./commands/feedback.js" @@ -46,6 +53,7 @@ program .addCommand(hookCmd) .addCommand(statuslineCmd) .addCommand(discoverCmd) + .addCommand(openCmd) .addCommand(skipCmd) .addCommand(killSessionCmd) .addCommand(muteCmd) diff --git a/packages/cli/src/lib/__tests__/ad-impression.test.ts b/packages/cli/src/lib/__tests__/ad-impression.test.ts new file mode 100644 index 00000000..de8d26a7 --- /dev/null +++ b/packages/cli/src/lib/__tests__/ad-impression.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from "vitest" +import { buildAdImpression, toIngestImpression } from "../ad-impression.js" + +const slot = { + kind: "sponsored" as const, + adId: "house:neon", + source: "house" as const, + advertiser: "Neon", + headline: "x", + ctaText: "Start", + displayUrl: "neon.tech", + clickUrl: "http://localhost:3011/ads/click/abc", + deliveryId: "3f0c2f0e-7f1a-4b57-9d9e-0d8f4f3f2a11", + cpmRate: 10, +} + +describe("buildAdImpression", () => { + it("maps a sponsored slot onto the ledger row", () => { + const row = buildAdImpression({ + id: "i1", + slot, + shownAt: 1000, + durationMs: 12000, + result: "completed", + deviceId: "dev1", + }) + expect(row).toEqual({ + id: "i1", + adId: "house:neon", + campaignId: "house", + surface: "terminal-tv", + source: "house", + deliveryToken: slot.deliveryId, + startedAt: 1000, + durationMs: 12000, + result: "completed", + deviceId: "dev1", + cpmRate: 10, + }) + }) +}) + +describe("toIngestImpression", () => { + it("sends only what the server needs", () => { + const row = buildAdImpression({ + id: "i1", + slot, + shownAt: 1000, + durationMs: 800, + result: "interrupted", + deviceId: "dev1", + }) + expect(toIngestImpression(row)).toEqual({ + deliveryToken: slot.deliveryId, + durationMs: 800, + result: "interrupted", + }) + }) +}) diff --git a/packages/cli/src/lib/__tests__/ledger.test.ts b/packages/cli/src/lib/__tests__/ledger.test.ts index c016cea7..f30b8a46 100644 --- a/packages/cli/src/lib/__tests__/ledger.test.ts +++ b/packages/cli/src/lib/__tests__/ledger.test.ts @@ -260,4 +260,19 @@ describe("v2 clicks table", () => { expect(ledger.listUnsyncedClicks(10)).toHaveLength(0) ledger.close() }) + + it("sumTodayOptimistic follows the server pay rule: on screen >= 1s and not skipped", async () => { + const { openLedger } = await import("../ledger.js") + const ledger = openLedger() + const now = Date.now() + const base = { startedAt: now, cpmRate: 10 } + ledger.record(sampleImpression({ ...base, id: "a", result: "completed", durationMs: 12000 })) + ledger.record(sampleImpression({ ...base, id: "b", result: "interrupted", durationMs: 5000 })) + ledger.record(sampleImpression({ ...base, id: "c", result: "completed", durationMs: 500 })) + ledger.record(sampleImpression({ ...base, id: "d", result: "skipped", durationMs: 6000 })) + ledger.record(sampleImpression({ ...base, id: "e", result: "expired", durationMs: 2000 })) + // paid: a, b, e — three impressions at $10 cpm x 70% share + expect(ledger.sumTodayOptimistic(0, now)).toBeCloseTo(0.021, 6) + ledger.close() + }) }) diff --git a/packages/cli/src/lib/ad-impression.ts b/packages/cli/src/lib/ad-impression.ts new file mode 100644 index 00000000..551108ac --- /dev/null +++ b/packages/cli/src/lib/ad-impression.ts @@ -0,0 +1,36 @@ +import type { SponsoredPayload } from "@distrotv/shared" +import type { ImpressionResult, LocalImpression } from "./ledger.js" + +export interface IngestImpressionItem { + deliveryToken: string + durationMs: number + result: string +} + +export function buildAdImpression(args: { + id: string + slot: SponsoredPayload + shownAt: number + durationMs: number + result: ImpressionResult + deviceId: string +}): LocalImpression { + return { + id: args.id, + adId: args.slot.adId, + campaignId: args.slot.source, + surface: "terminal-tv", + source: args.slot.source, + deliveryToken: args.slot.deliveryId, + startedAt: args.shownAt, + durationMs: args.durationMs, + result: args.result, + deviceId: args.deviceId, + cpmRate: args.slot.cpmRate, + } +} + +// user + device come from the server-side delivery row, so only the view facts go up +export function toIngestImpression(row: LocalImpression): IngestImpressionItem { + return { deliveryToken: row.deliveryToken, durationMs: row.durationMs, result: row.result } +} diff --git a/packages/cli/src/lib/api-client.ts b/packages/cli/src/lib/api-client.ts index b1f746c3..420fada0 100644 --- a/packages/cli/src/lib/api-client.ts +++ b/packages/cli/src/lib/api-client.ts @@ -1,3 +1,4 @@ +import type { IngestImpressionItem } from "./ad-impression.js" import { resolveEnv } from "@distrotv/shared" import { CONFIG_VERSION, @@ -198,16 +199,15 @@ export interface IngestNewsImpressionItem { } export interface IngestResponse { - // post-pivot: only newsImpressions are real. impressions/clicks kept as empty - // arrays so existing sync.ts applyResults loop doesn't break on index access. + // index-aligned with the request arrays impressions: { ok: boolean; deliveryToken: string; error?: string }[] clicks: { ok: boolean; deliveryToken: string; error?: string }[] newsImpressions?: IngestItemResultNewsImpression[] } export interface IngestRequest { - // legacy ad/click fields: CLI sends empty arrays; API accepts + ignores them - impressions: { deliveryToken: string }[] + // ad impressions; clicks are recorded server-side and stay an empty array + impressions: IngestImpressionItem[] clicks: { deliveryToken: string }[] newsImpressions?: IngestNewsImpressionItem[] } diff --git a/packages/cli/src/lib/daemon/display.ts b/packages/cli/src/lib/daemon/display.ts index e0cb6b89..fbb19e87 100644 --- a/packages/cli/src/lib/daemon/display.ts +++ b/packages/cli/src/lib/daemon/display.ts @@ -11,6 +11,9 @@ import type { CachedSlot } from "../slot-cache.js" // so force it on unless the user opted out via NO_COLOR. const RENDER_COLOR: ColorMode = process.env["NO_COLOR"] ? "none" : "truecolor" const FALLBACK_COLS = 80 +// OSC 8 links on the sponsor line. opt-in until verified against the status line +// of the Claude Code version in use: DISTRO_HYPERLINKS=1. +const HYPERLINKS = process.env["DISTRO_HYPERLINKS"] === "1" // Read the terminal width off the tty path the hook handed us, so the panel can // spread price/change/age across the real width. Best-effort — falls back to 80. @@ -30,6 +33,8 @@ function readTtyCols(ttyPath: string): number { export interface RenderCtx { source?: string width?: number + // today's estimated developer share, shown on sponsored panels + earnedTodayUsd?: number } export interface DisplayHandle { @@ -54,7 +59,12 @@ export interface DisplayHandle { export function showAd(ttyPath: string, slot: CachedSlot, ctx: RenderCtx = {}): DisplayHandle { try { const width = ctx.width ?? readTtyCols(ttyPath) - 4 - writeStatusLine(renderSlotLine(slot, RENDER_COLOR, width, getPendingUpdate() ?? undefined)) + writeStatusLine( + renderSlotLine(slot, RENDER_COLOR, width, getPendingUpdate() ?? undefined, { + earnedTodayUsd: ctx.earnedTodayUsd, + hyperlinks: HYPERLINKS, + }) + ) } catch { /* display must never throw — the daemon stays up no matter what */ } diff --git a/packages/cli/src/lib/daemon/orchestrator.ts b/packages/cli/src/lib/daemon/orchestrator.ts index 60088e6b..cabe2308 100644 --- a/packages/cli/src/lib/daemon/orchestrator.ts +++ b/packages/cli/src/lib/daemon/orchestrator.ts @@ -15,7 +15,7 @@ import { } from "@distrotv/shared" import type { SlotCache, CachedSlot } from "../slot-cache.js" import type { UtilityProvider } from "../utility-slot.js" -import type { Ledger, LocalNewsImpression } from "../ledger.js" +import type { Ledger, LocalImpression, LocalNewsImpression } from "../ledger.js" import type { KeyCapture } from "./input.js" import { step, type Effect, type Event, type State } from "./state-machine.js" import { writeNowPlaying } from "../now-playing-writer.js" @@ -31,7 +31,7 @@ export interface DisplayApi { show( ttyPath: string, slot: CachedSlot, - ctx: { source?: string; width?: number } + ctx: { source?: string; width?: number; earnedTodayUsd?: number } ): DisplayHandleApi } @@ -233,7 +233,14 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator { } const ttyVal = "tty" in event ? event.tty : undefined if (ttyVal !== undefined) { - return getOrCreateSession(ttyKey(ttyVal), ttyVal) + // cli actions (`dtv open`, skip, mute…) are usually typed in a different + // terminal than the one showing the slot. if the invoking tty has nothing + // on screen, fall through and target the session that does. + const own = sessions.get(ttyKey(ttyVal)) + const isKeyAction = event.kind.endsWith("-key") + if (!isKeyAction || own?.state.kind === "SHOWING") { + return getOrCreateSession(ttyKey(ttyVal), ttyVal) + } } // infer: single active (non-IDLE) session takes the event; otherwise the // single session overall; otherwise create/use the null-tty sentinel so @@ -434,7 +441,9 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator { ? effect.ad.id : effect.ad.kind === "ticker" ? effect.ad.symbol - : "utility" + : effect.ad.kind === "sponsored" + ? effect.ad.adId + : "utility" if (!effect.tty) { deps.log.warn("display skipped: no tty path", { payloadId }) queueMicrotask(() => dispatch({ kind: "dismiss", now: now(), tty: session.tty })) @@ -442,7 +451,15 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator { } try { const source = effect.ad.cacheSource === "demo" ? "DEMO" : undefined - session.currentDisplay = deps.display.show(effect.tty, effect.ad, { source }) + // sponsored panels show today's estimated share from the local ledger + const earnedTodayUsd = + effect.ad.kind === "sponsored" + ? deps.ledger.sumTodayOptimistic(preferences.tzOffsetMinutes) + : undefined + session.currentDisplay = deps.display.show(effect.tty, effect.ad, { + source, + earnedTodayUsd, + }) session.currentSlotKind = effect.ad.kind // on terminal resize, proactively dismiss the current ad — the // display has already reset its scroll region to prevent content @@ -621,21 +638,31 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator { const url = effect.ad.chartUrl ?? buildBareSymbolUrl(effect.ad.symbol, effect.ad.assetClass) deps.openUrl(url) + } else if (effect.ad.kind === "sponsored") { + deps.openUrl(effect.ad.clickUrl) } } catch (err) { deps.log.warn("openUrl failed", { error: (err as Error).message }) } - // news clicks recorded server-side via openedUrl flag on news impression + // news clicks ride the openedUrl flag on the news impression; ad clicks are + // recorded server-side by the /ads/click redirect. return } } } - function handleRecord(_imp: unknown, slot: CachedSlot): void { - if (slot.kind === "news") { - // news impressions are ledgered separately via recordNewsImpression - deps.log.debug("skipping ad-ledger write for news slot", { newsId: slot.id }) - return + function handleRecord(imp: LocalImpression, slot: CachedSlot): void { + // only sponsored slots use the ad ledger; news is ledgered via recordNewsImpression + if (slot.kind !== "sponsored") return + try { + deps.ledger.record(imp) + deps.log.info("ad impression recorded", { + adId: imp.adId, + result: imp.result, + durationMs: imp.durationMs, + }) + } catch (err) { + deps.log.warn("ad impression ledger write failed", { error: (err as Error).message }) } } diff --git a/packages/cli/src/lib/daemon/state-machine.ts b/packages/cli/src/lib/daemon/state-machine.ts index 525627e3..e29a9e47 100644 --- a/packages/cli/src/lib/daemon/state-machine.ts +++ b/packages/cli/src/lib/daemon/state-machine.ts @@ -8,6 +8,7 @@ import { } from "@distrotv/shared" import type { CachedSlot } from "../slot-cache.js" import type { ImpressionResult, LocalImpression, LocalNewsImpression } from "../ledger.js" +import { buildAdImpression } from "../ad-impression.js" export type State = | { kind: "IDLE" } @@ -190,8 +191,8 @@ function stepShowing( } } if (event.kind === "discover-key") { - // discover opens the advertiser URL in the browser AND keeps rotation - // going so the user doesn't lose the ad stream while Claude is still busy. + // discover/open: opens the sponsor or story in the browser AND keeps rotation + // going so the user doesn't lose the stream while Claude is still busy. const base = endShowing(state, event.now, ctx, "completed", /*goToInterAd*/ true) const deliveryToken = "" return { @@ -309,6 +310,20 @@ function endShowing( } cleanup.push({ kind: "recordNewsImpression", impression: newsImpression, ad: slot }) } + if (slot.kind === "sponsored") { + cleanup.push({ + kind: "recordImpression", + impression: buildAdImpression({ + id: randomUUID(), + slot, + shownAt: state.shownAt, + durationMs, + result, + deviceId: ctx.deviceId, + }), + ad: slot, + }) + } if (goToInterAd) { return { diff --git a/packages/cli/src/lib/daemon/sync.ts b/packages/cli/src/lib/daemon/sync.ts index 90aecfa0..eda4a171 100644 --- a/packages/cli/src/lib/daemon/sync.ts +++ b/packages/cli/src/lib/daemon/sync.ts @@ -1,6 +1,7 @@ import type { Ledger, LocalImpression, LocalClick, LocalNewsImpression } from "../ledger.js" import { ApiError, postIngest, postReadingSave, type IngestResponse } from "../api-client.js" import { syncPreferencesOnce } from "./prefs-sync.js" +import { toIngestImpression } from "../ad-impression.js" export interface SyncLogger { debug(msg: string, fields?: Record): void @@ -109,9 +110,8 @@ export function createSyncLoop(deps: SyncLoopDeps): SyncLoop { } async function doRunOnce(): Promise { - // post-pivot: ad impressions/clicks are gone. listUnsynced + listUnsyncedClicks - // will always return [] but we still call them so the ledger stays coherent if - // a legacy ledger row somehow exists. + // ad impressions ride the same batch as news impressions. clicks are recorded + // server-side by the /ads/click redirect, so that list only drains legacy rows. const impressions = deps.ledger.listUnsynced(IMPRESSION_BATCH_CAP) const clicks = deps.ledger.listUnsyncedClicks(CLICK_BATCH_CAP) const newsImpressions = deps.ledger.listUnsyncedNewsImpressions(NEWS_IMPRESSION_BATCH_CAP) @@ -124,13 +124,11 @@ export function createSyncLoop(deps: SyncLoopDeps): SyncLoop { terminal: 0, } - if (newsImpressions.length > 0) { + if (newsImpressions.length > 0 || impressions.length > 0) { let res: IngestResponse try { const body = { - // M1: only newsImpressions go to the server. impressions/clicks are - // dead post-pivot; omit them to keep the request body clean. - impressions: [] as { deliveryToken: string }[], + impressions: impressions.map(toIngestImpression), clicks: [] as { deliveryToken: string }[], newsImpressions: newsImpressions.map((n) => ({ newsId: n.newsId, diff --git a/packages/cli/src/lib/ledger.ts b/packages/cli/src/lib/ledger.ts index 66632813..630544b6 100644 --- a/packages/cli/src/lib/ledger.ts +++ b/packages/cli/src/lib/ledger.ts @@ -1,7 +1,7 @@ import { chmodSync, mkdirSync, renameSync } from "node:fs" import { join } from "node:path" import Database, { type Statement } from "better-sqlite3" -import { REVENUE_SHARE_DEVELOPER } from "@distrotv/shared" +import { MIN_COMPLETED_DURATION_MS, REVENUE_SHARE_DEVELOPER } from "@distrotv/shared" import { configDir } from "./config.js" export type ImpressionResult = "completed" | "skipped" | "expired" | "interrupted" @@ -55,10 +55,11 @@ export interface Ledger { markSynced(ids: string[], at: number): void markImpressionsTerminal(ids: string[]): void unsyncedCount(): number - // Optimistic running total of developer-share USDC earned today (local day - // per tzOffsetMinutes). Sums (cpm_rate / 1000) * REVENUE_SHARE_DEVELOPER over - // completed impressions whose started_at falls in today's local window. Used - // by the S3-05 earnings toast; backend remains authoritative at sync time. + // Optimistic running total of the estimated developer share earned today (local + // day per tzOffsetMinutes). Sums (cpm_rate / 1000) * REVENUE_SHARE_DEVELOPER over + // viewable impressions (on screen >= 1s, not skipped — same rule as the server) + // whose started_at falls in today's local window. Shown on the sponsored panel; + // the backend remains authoritative at sync time. sumTodayOptimistic(tzOffsetMinutes: number, now?: number): number // count of today's UTC-day impressions for a given campaignId (any // `result`). used by the daemon to enforce per-campaign daily caps locally, @@ -290,7 +291,8 @@ export function openLedger(): Ledger { const sumTodayStmt = db.prepare(` SELECT COALESCE(SUM(cpm_rate), 0) AS total_cpm FROM impressions - WHERE result = 'completed' + WHERE result != 'skipped' + AND duration_ms >= ${MIN_COMPLETED_DURATION_MS} AND cpm_rate IS NOT NULL AND started_at >= ? AND started_at < ? diff --git a/packages/cli/src/lib/slot-cache.ts b/packages/cli/src/lib/slot-cache.ts index e1893a30..dafbc8f3 100644 --- a/packages/cli/src/lib/slot-cache.ts +++ b/packages/cli/src/lib/slot-cache.ts @@ -36,16 +36,18 @@ export function resetSlotIndex(): void { const CACHE_TTL_MS = 15 * 60 * 1000 const REFRESH_THRESHOLD = 3 const BATCH_SIZE = 20 -// Bumped to 3: cache now stores SlotPayload (discriminated union), not just CachedAd. +// Bumped to 4: sponsored slots joined the union and ads became the default feed — +// dropping older caches makes the new mix show up immediately. +// (3: cache stores SlotPayload, a discriminated union, not just CachedAd.) // Old "ad-cache.json" (version 2) will fail the check and be silently dropped. -const CACHE_FILE_VERSION = 3 +const CACHE_FILE_VERSION = 4 // "local" tags slots the daemon builds itself (CH 03 utility panel) — never // fetched from /me/content/next and never written to the cache file. export type CachedSlot = SlotPayload & { cacheSource: "api" | "demo" | "local" } interface CacheFile { - version: 3 + version: typeof CACHE_FILE_VERSION userId: string deviceId: string surface: string From 32cde58ab7ca4aa009cc284e160b951e35ba165a Mon Sep 17 00:00:00 2001 From: distroinfinity Date: Sat, 19 Sep 2026 11:23:26 -0700 Subject: [PATCH 10/35] feat(cli): feeds preference, ads-first onboarding and status --- packages/cli/src/commands/daemon.ts | 11 +++-- packages/cli/src/commands/init.ts | 46 +++++++++++++++----- packages/cli/src/commands/preferences.ts | 23 ++++++++-- packages/cli/src/commands/status.ts | 34 +++++++++++---- packages/cli/src/lib/__tests__/feeds.test.ts | 29 ++++++++++++ packages/cli/src/lib/daemon/prefs-sync.ts | 2 + packages/cli/src/lib/feeds.ts | 20 +++++++++ packages/cli/src/lib/preferences-client.ts | 3 +- packages/cli/src/lib/prompts/feeds.ts | 24 ++++++++++ 9 files changed, 167 insertions(+), 25 deletions(-) create mode 100644 packages/cli/src/lib/__tests__/feeds.test.ts create mode 100644 packages/cli/src/lib/feeds.ts create mode 100644 packages/cli/src/lib/prompts/feeds.ts diff --git a/packages/cli/src/commands/daemon.ts b/packages/cli/src/commands/daemon.ts index 10a3d02f..2a8e067f 100644 --- a/packages/cli/src/commands/daemon.ts +++ b/packages/cli/src/commands/daemon.ts @@ -379,10 +379,15 @@ export async function runDaemon(): Promise { preferences: cfg.preferences, }) - function fingerprintPrefs(p: { blockedCategories: string[]; channelMode: string }): string { + function fingerprintPrefs(p: { + blockedCategories: string[] + channelMode: string + enabledFeeds?: string[] + }): string { return JSON.stringify({ blockedCategories: [...p.blockedCategories].sort(), channelMode: p.channelMode, + enabledFeeds: [...(p.enabledFeeds ?? [])].sort(), }) } @@ -397,8 +402,8 @@ export async function runDaemon(): Promise { const nextFp = fingerprintPrefs(next.preferences) if (nextFp !== lastPrefsFingerprint) { lastPrefsFingerprint = nextFp - // blocked categories or channelMode changed — the existing cache may - // contain now-blocked ads or wrong-mode content. Force a refresh so + // blocked categories, channelMode or feeds changed — the existing cache may + // contain now-blocked ads or the wrong mix. Force a refresh so // the next display reflects the new settings. slotCache.refreshNow().catch((err: Error) => { log.warn("slot-cache refresh after preference change failed", { diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index cbd02140..9540a08d 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -4,8 +4,8 @@ import { homedir } from "node:os" import { lstatSync, mkdirSync, symlinkSync, unlinkSync } from "node:fs" import { join } from "node:path" import { Command } from "commander" -import { intro, outro, log, note, spinner } from "@clack/prompts" -import { ChannelMode } from "@distrotv/shared" +import { intro, outro, log, note, spinner, confirm, isCancel } from "@clack/prompts" +import { ChannelMode, type Feed } from "@distrotv/shared" import { ApiError, NotAuthenticatedError, @@ -31,6 +31,8 @@ import { getMyChannels, putMyChannels } from "../lib/channels-client.js" import { getMyWatchlists, putMyWatchlists } from "../lib/watchlists-client.js" import { pickChannelMode } from "../lib/prompts/preferences.js" import { pickChannels } from "../lib/prompts/channels.js" +import { pickFeeds } from "../lib/prompts/feeds.js" +import { channelModeForFeeds, describeFeeds } from "../lib/feeds.js" import { pickWatchlistTickers } from "../lib/prompts/watchlist.js" import { runInitHealthCheck, type Probe } from "../lib/health.js" import { runDemo } from "./demo.js" @@ -205,9 +207,9 @@ async function waitForPair(code: string, ttlSec: number): Promise { +async function savePreferences(channelMode: ChannelMode, enabledFeeds: Feed[]): Promise { const tzOffsetMinutes = -new Date().getTimezoneOffset() - const updated = await putPreferences({ tzOffsetMinutes, channelMode }) + const updated = await putPreferences({ tzOffsetMinutes, channelMode, enabledFeeds }) // mirror to local config so daemon/demo/preferences see the new mode without // waiting on the next prefs-sync tick const cfg = await readConfig() @@ -221,7 +223,7 @@ async function savePreferences(channelMode: ChannelMode): Promise { preferences: { ...cfg.preferences, ...updated }, }) } - log.success(`preferences saved (mode: ${channelMode})`) + log.success(`preferences saved (playing: ${describeFeeds(enabledFeeds)})`) } async function installHooks(): Promise { @@ -347,11 +349,33 @@ export async function runInit(): Promise { await ensureClaudeDir() await ensureSignedInOrPair() - const channelMode = await pickChannelMode() + note( + [ + "sponsored slots play in your status line while your agent works.", + "you earn an estimated 70% share of every ad you actually see.", + "they vanish the moment you type. turn them off anytime: `dtv preferences`.", + ].join("\n"), + "ads are on" + ) - await savePreferences(channelMode) + // channels are opt-in: news / markets rotate in alongside ads only if asked for. + const wantsChannels = await confirm({ + message: "also tune in to news or markets channels? (optional)", + initialValue: false, + }) + let enabledFeeds: Feed[] = ["ads"] + if (!isCancel(wantsChannels) && wantsChannels) { + const content = await pickFeeds([], ["news", "markets"]) + enabledFeeds = ["ads", ...content] + } + const both = enabledFeeds.includes("news") && enabledFeeds.includes("markets") + const channelMode = both + ? await pickChannelMode() + : channelModeForFeeds(enabledFeeds, ChannelMode.Balanced) - if (channelMode !== ChannelMode.TickerOnly) { + await savePreferences(channelMode, enabledFeeds) + + if (enabledFeeds.includes("news")) { try { const current = await getMyChannels() const next = await pickChannels(current) @@ -371,7 +395,7 @@ export async function runInit(): Promise { } } - if (channelMode !== ChannelMode.NewsOnly) { + if (enabledFeeds.includes("markets")) { try { const tickers = await pickWatchlistTickers() if (tickers.length === 0) { @@ -419,7 +443,9 @@ export async function runInit(): Promise { outro("you're all set. a network check didn't pass just now — run `distro doctor` to recheck.") return } - outro("all set — open a new Claude Code session to start earning") + outro( + "all set — ads play while your agent works and you earn an estimated share. open a new Claude Code session to start." + ) } export const initCmd = new Command("init") diff --git a/packages/cli/src/commands/preferences.ts b/packages/cli/src/commands/preferences.ts index 72fa6e71..42cb7bd4 100644 --- a/packages/cli/src/commands/preferences.ts +++ b/packages/cli/src/commands/preferences.ts @@ -9,9 +9,12 @@ import { getMyWatchlists, putMyWatchlists } from "../lib/watchlists-client.js" import { getMyAlerts, putMyAlerts } from "../lib/alerts-client.js" import { pickChannelMode } from "../lib/prompts/preferences.js" import { pickChannels } from "../lib/prompts/channels.js" +import { pickFeeds } from "../lib/prompts/feeds.js" +import { channelModeForFeeds, describeFeeds } from "../lib/feeds.js" import { pickWatchlistTickers } from "../lib/prompts/watchlist.js" type Action = + | "feeds" | "mode" | "channels" | "watchlist" @@ -44,11 +47,12 @@ async function mirrorToLocal(updated: SyncedPreferences): Promise { }) } -async function showMenu(currentMode: ChannelMode): Promise { +async function showMenu(currentMode: ChannelMode, feeds: string): Promise { const choice = await select({ message: "what would you like to change?", options: [ - { value: "mode", label: `channel mode (currently: ${currentMode})` }, + { value: "feeds", label: `feeds — ads / news / markets (currently: ${feeds})` }, + { value: "mode", label: `news : markets ratio (currently: ${currentMode})` }, { value: "channels", label: "channels (tech / finance / crypto / …)" }, { value: "watchlist", label: "watchlist (add / remove tickers)" }, { value: "alerts", label: "alerts (global threshold)" }, @@ -167,9 +171,22 @@ async function runPreferences(): Promise { // loop until cancel so multi-edit in one session works while (true) { - const action = await showMenu(prefs.channelMode) + const action = await showMenu(prefs.channelMode, describeFeeds(prefs.enabledFeeds ?? [])) if (action === "cancel") break + if (action === "feeds") { + const next = await pickFeeds(prefs.enabledFeeds ?? ["ads"]) + prefs = await putPreferences({ + enabledFeeds: next, + channelMode: channelModeForFeeds(next, prefs.channelMode), + }) + await mirrorToLocal(prefs) + log.success(`now playing: ${describeFeeds(next)}`) + if (!next.includes("ads")) log.warn("ads are off — you earn nothing while they're off.") + if (next.length === 0) log.warn("nothing will play until you turn a feed back on.") + continue + } + if (action === "mode") { const next = await pickChannelMode(prefs.channelMode) if (next === prefs.channelMode) { diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index 1ca76a78..ede4cf3a 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -20,6 +20,9 @@ interface StatusPayload { installed: boolean mode: string | null channels: string[] + feeds: string[] + utilities: boolean + estEarnedTodayUsd: number daemon: DaemonStatus slotCache: SlotCacheInfo unsyncedImpressions: number @@ -43,6 +46,9 @@ export const statusCmd = new Command("status") installed: cfg !== null, mode: cfg?.preferences.channelMode ?? null, channels: resolveChannels(cfg), + feeds: cfg?.preferences.enabledFeeds ?? [], + utilities: cfg?.preferences.utilitiesEnabled ?? false, + estEarnedTodayUsd: readEarnedToday(cfg), daemon, slotCache, unsyncedImpressions: unsynced, @@ -102,13 +108,23 @@ function readUnsyncedCount(): number { } } +// content channels that are actually on (feeds decide; ads are reported separately) function resolveChannels(cfg: DevdripConfig | null): string[] { if (!cfg) return [] - const mode = cfg.preferences.channelMode - if (mode === "ticker_only") return ["markets"] - if (mode === "news_only") return ["news"] - // balanced/news_heavy/ticker_heavy or default - return ["news", "markets"] + const feeds = cfg.preferences.enabledFeeds ?? [] + return (["news", "markets"] as const).filter((c) => feeds.includes(c)) +} + +function readEarnedToday(cfg: DevdripConfig | null): number { + if (!cfg || !existsSync(ledgerPath())) return 0 + const ledger = openLedger() + try { + return ledger.sumTodayOptimistic(cfg.preferences.tzOffsetMinutes) + } catch { + return 0 + } finally { + ledger.close() + } } function formatUptime(ms: number): string { @@ -139,9 +155,11 @@ function printHuman(p: StatusPayload, cfg: DevdripConfig | null): void { const login = cfg?.user.githubLogin if (login) console.log(`signed in: @${login}`) - console.log(`mode: ${p.mode ?? "unknown"}`) - console.log(`channels: ${p.channels.length > 0 ? p.channels.join(", ") : "none"}`) - console.log(`watchlist: (lands in M4)`) + console.log(`ads: ${p.feeds.includes("ads") ? "on" : "off"}`) + console.log(`channels: ${p.channels.length > 0 ? p.channels.join(", ") : "none (opt-in)"}`) + if (p.channels.length === 2) console.log(`ratio: ${p.mode ?? "unknown"}`) + console.log(`utilities: ${p.utilities ? "on" : "off"}`) + console.log(`est. earned today: $${p.estEarnedTodayUsd.toFixed(4)}`) printSlotCache(p.slotCache) printDaemon(p.daemon) diff --git a/packages/cli/src/lib/__tests__/feeds.test.ts b/packages/cli/src/lib/__tests__/feeds.test.ts new file mode 100644 index 00000000..64dfabeb --- /dev/null +++ b/packages/cli/src/lib/__tests__/feeds.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from "vitest" +import { ChannelMode } from "@distrotv/shared" +import { channelModeForFeeds, describeFeeds } from "../feeds.js" + +describe("channelModeForFeeds", () => { + it("news only → news_only, markets only → ticker_only", () => { + expect(channelModeForFeeds(["ads", "news"], ChannelMode.Balanced)).toBe(ChannelMode.NewsOnly) + expect(channelModeForFeeds(["markets"], ChannelMode.Balanced)).toBe(ChannelMode.TickerOnly) + }) + it("both → keeps a mixed mode, or falls back to balanced", () => { + expect(channelModeForFeeds(["news", "markets"], ChannelMode.NewsHeavy)).toBe( + ChannelMode.NewsHeavy + ) + expect(channelModeForFeeds(["news", "markets"], ChannelMode.NewsOnly)).toBe( + ChannelMode.Balanced + ) + }) + it("no content feeds → leaves the mode alone", () => { + expect(channelModeForFeeds(["ads"], ChannelMode.TickerHeavy)).toBe(ChannelMode.TickerHeavy) + }) +}) + +describe("describeFeeds", () => { + it("lists what plays", () => { + expect(describeFeeds(["ads"])).toBe("ads") + expect(describeFeeds(["ads", "news", "markets"])).toBe("ads, news, markets") + expect(describeFeeds([])).toBe("nothing (all feeds off)") + }) +}) diff --git a/packages/cli/src/lib/daemon/prefs-sync.ts b/packages/cli/src/lib/daemon/prefs-sync.ts index 888d45f3..643ff1e2 100644 --- a/packages/cli/src/lib/daemon/prefs-sync.ts +++ b/packages/cli/src/lib/daemon/prefs-sync.ts @@ -48,6 +48,7 @@ export async function syncPreferencesOnce(log: SyncLogger): Promise feeds.includes(f)) + return ordered.length > 0 ? ordered.join(", ") : "nothing (all feeds off)" +} diff --git a/packages/cli/src/lib/preferences-client.ts b/packages/cli/src/lib/preferences-client.ts index a176b5e2..8607e2e1 100644 --- a/packages/cli/src/lib/preferences-client.ts +++ b/packages/cli/src/lib/preferences-client.ts @@ -1,4 +1,4 @@ -import type { AdCategory, ChannelMode, SyncedPreferences } from "@distrotv/shared" +import type { AdCategory, ChannelMode, Feed, SyncedPreferences } from "@distrotv/shared" import { apiFetch } from "./api-client.js" export interface PreferencesResponse { @@ -16,6 +16,7 @@ export interface UpdatePreferencesBody { idleSensitivityMs?: number sessionWarmupMs?: number nightMode?: boolean + enabledFeeds?: Feed[] } export async function getPreferences(): Promise { diff --git a/packages/cli/src/lib/prompts/feeds.ts b/packages/cli/src/lib/prompts/feeds.ts new file mode 100644 index 00000000..d15f2285 --- /dev/null +++ b/packages/cli/src/lib/prompts/feeds.ts @@ -0,0 +1,24 @@ +import { multiselect, isCancel, cancel } from "@clack/prompts" +import { FEEDS, type Feed } from "@distrotv/shared" + +const LABELS: Record = { + ads: { label: "Ads", hint: "sponsored slots — you earn an estimated 70% share" }, + news: { label: "News", hint: "CH 01 — top tech + finance stories" }, + markets: { label: "Markets", hint: "CH 02 — your watchlist" }, +} + +// what rotates in the terminal while the agent works. an empty pick is valid (everything off). +export async function pickFeeds(current: Feed[], only: Feed[] = FEEDS): Promise { + const selected = await multiselect({ + message: "what plays while your agent works? (space to toggle · enter to confirm)", + options: only.map((f) => ({ value: f, label: LABELS[f].label, hint: LABELS[f].hint })), + initialValues: current.filter((f) => only.includes(f)), + required: false, + }) + if (isCancel(selected)) { + cancel("cancelled") + process.exit(0) + } + const set = new Set(selected as Feed[]) + return FEEDS.filter((f) => set.has(f)) +} From 08f9d707f15912d6b9a95baf5e948eaa69bb2063 Mon Sep 17 00:00:00 2001 From: distroinfinity Date: Sat, 19 Sep 2026 11:26:13 -0700 Subject: [PATCH 11/35] feat(cli): 4-decimal sub-dollar totals, dev link script --- packages/cli/src/commands/status.ts | 3 +- .../lib/__tests__/render-sponsored.test.ts | 8 ++++-- packages/cli/src/lib/render-sponsored.ts | 7 ++++- scripts/dev-link-cli.sh | 28 +++++++++++++++++++ 4 files changed, 42 insertions(+), 4 deletions(-) create mode 100755 scripts/dev-link-cli.sh diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index ede4cf3a..445f7196 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -5,6 +5,7 @@ import { reportError } from "../lib/api-client.js" import { readConfig, type DevdripConfig } from "../lib/config.js" import { readDaemonStatus, type DaemonStatus } from "../lib/daemon/lifecycle.js" import { ledgerPath, openLedger } from "../lib/ledger.js" +import { formatEarned } from "../lib/render-sponsored.js" import { slotCachePath } from "../lib/slot-cache.js" import { checkForUpdate } from "../lib/upgrade-check.js" @@ -159,7 +160,7 @@ function printHuman(p: StatusPayload, cfg: DevdripConfig | null): void { console.log(`channels: ${p.channels.length > 0 ? p.channels.join(", ") : "none (opt-in)"}`) if (p.channels.length === 2) console.log(`ratio: ${p.mode ?? "unknown"}`) console.log(`utilities: ${p.utilities ? "on" : "off"}`) - console.log(`est. earned today: $${p.estEarnedTodayUsd.toFixed(4)}`) + console.log(`est. earned today: $${formatEarned(p.estEarnedTodayUsd)}`) printSlotCache(p.slotCache) printDaemon(p.daemon) diff --git a/packages/cli/src/lib/__tests__/render-sponsored.test.ts b/packages/cli/src/lib/__tests__/render-sponsored.test.ts index 6ab5c0fb..66b3ad1f 100644 --- a/packages/cli/src/lib/__tests__/render-sponsored.test.ts +++ b/packages/cli/src/lib/__tests__/render-sponsored.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from "vitest" import type { SponsoredPayload } from "@distrotv/shared" import { renderSlotLine } from "../render-line.js" -import { perImpressionUsd } from "../render-sponsored.js" +import { formatEarned, perImpressionUsd } from "../render-sponsored.js" const ad: SponsoredPayload & { cacheSource: "api" } = { kind: "sponsored", @@ -20,6 +20,10 @@ const strip = (s: string): string => s.replace(/\x1b\][^\x1b]*\x1b\\/g, "").replace(/\x1b\[[0-9;]*m/g, "") describe("sponsored panel", () => { + it("keeps sub-dollar totals at 4 decimals", () => { + expect(formatEarned(0.028)).toBe("0.0280") + expect(formatEarned(12.5)).toBe("12.50") + }) it("estimates the per-impression share", () => { expect(perImpressionUsd(10)).toBeCloseTo(0.007, 6) }) @@ -32,7 +36,7 @@ describe("sponsored panel", () => { expect(out).toContain("sentry.io") expect(out).toContain("dtv open") expect(out).toContain("+$0.0070 est") - expect(out).toContain("today $0.42") + expect(out).toContain("today $0.4200") }) it("says demo for house ads", () => { expect(renderSlotLine({ ...ad, source: "house" }, "none", 80)).toContain("via Distro") diff --git a/packages/cli/src/lib/render-sponsored.ts b/packages/cli/src/lib/render-sponsored.ts index b5ed2832..d434bef1 100644 --- a/packages/cli/src/lib/render-sponsored.ts +++ b/packages/cli/src/lib/render-sponsored.ts @@ -8,6 +8,11 @@ export interface RenderExtras { hyperlinks?: boolean } +// sub-dollar totals keep 4 decimals so per-ad progress stays visible +export function formatEarned(usd: number): string { + return usd < 1 ? usd.toFixed(4) : usd.toFixed(2) +} + export function perImpressionUsd(cpmRate: number): number { return (cpmRate / 1000) * REVENUE_SHARE_DEVELOPER } @@ -46,7 +51,7 @@ export function renderSponsoredPanel( const host = link(color("indigo", `↗ ${clean(slot.displayUrl)}`, mode), slot.clickUrl, useLinks) const today = extras.earnedTodayUsd != null - ? ` ${dot} ${color("muted", `today $${extras.earnedTodayUsd.toFixed(2)}`, mode)}` + ? ` ${dot} ${color("muted", `today $${formatEarned(extras.earnedTodayUsd)}`, mode)}` : "" // no spread() on this line — OSC 8 bytes would break its width maths const action = `${LEFT_PAD}${host} ${dot} ${color("muted", "dtv open", mode)}${today}` diff --git a/scripts/dev-link-cli.sh b/scripts/dev-link-cli.sh new file mode 100755 index 00000000..3b306cdd --- /dev/null +++ b/scripts/dev-link-cli.sh @@ -0,0 +1,28 @@ +#!/bin/sh +# dev only: point the installed layout (~/.distrotv/current + shims) at this +# checkout's packages/cli build, so claude code hooks, the status line and the +# daemon all run the working tree. re-run after moving the repo; rebuilds are +# picked up automatically (pnpm --filter @distrotv/cli build, then `dtv daemon stop`). +set -e +REPO="$(cd "$(dirname "$0")/.." && pwd)" +CLI="$REPO/packages/cli" +HOME_DIR="${DISTROTV_HOME:-$HOME/.distrotv}" +BIN_DIR="${DISTROTV_BIN:-$HOME/.local/bin}" +VER="$(node -p "require('$CLI/package.json').version")-dev" + +[ -f "$CLI/dist/index.js" ] || { echo "✗ build first: pnpm --filter @distrotv/cli build" >&2; exit 1; } + +mkdir -p "$HOME_DIR/versions/$VER" "$BIN_DIR" +ln -sfn "$CLI/dist" "$HOME_DIR/versions/$VER/dist" +ln -sfn "$CLI/node_modules" "$HOME_DIR/versions/$VER/node_modules" +ln -sfn "$CLI/package.json" "$HOME_DIR/versions/$VER/package.json" +ln -sfn "versions/$VER" "$HOME_DIR/current" +chmod +x "$CLI/dist/index.js" + +for n in distro dtv; do + printf '#!/bin/sh\n# dev shim — runs %s\nexec node "%s/current/dist/index.js" "$@"\n' "$CLI" "$HOME_DIR" > "$BIN_DIR/$n" + chmod +x "$BIN_DIR/$n" +done + +echo "✓ linked $HOME_DIR/current → $CLI" +echo " next: DISTRO_ENV=local dtv init (then set cli.autoUpdate=false in ~/.distro/config.json)" From cc1d9ae9e5012241cd587b29c3de8ca8690685c7 Mon Sep 17 00:00:00 2001 From: distroinfinity Date: Sat, 19 Sep 2026 11:31:22 -0700 Subject: [PATCH 12/35] feat: short click links + simpler, bar-led sponsored panel --- .../api/src/__tests__/ad-impression.test.ts | 22 +++++++++ packages/api/src/app.ts | 3 +- packages/api/src/routes/ads-click.ts | 16 ++++++- .../api/src/services/ad-impression.service.ts | 33 ++++++++++++- .../api/src/services/ad-supply.service.ts | 4 +- .../lib/__tests__/render-sponsored.test.ts | 47 +++++++++++++------ packages/cli/src/lib/render-sponsored.ts | 47 ++++++++++++------- packages/cli/src/lib/render-utils.ts | 3 +- 8 files changed, 139 insertions(+), 36 deletions(-) diff --git a/packages/api/src/__tests__/ad-impression.test.ts b/packages/api/src/__tests__/ad-impression.test.ts index 3c5da9c1..4ebeaa82 100644 --- a/packages/api/src/__tests__/ad-impression.test.ts +++ b/packages/api/src/__tests__/ad-impression.test.ts @@ -35,3 +35,25 @@ describe("parseAdImpression", () => { expect(parseAdImpression({ ...ok, durationMs: 9_999_999 })?.durationMs).toBe(60_000) }) }) + +describe("click short codes", () => { + it("derives a 12-hex code from a delivery id", async () => { + const { clickCode } = await import("../services/ad-impression.service.js") + expect(clickCode("3f0c2f0e-7f1a-4b57-9d9e-0d8f4f3f2a11")).toBe("3f0c2f0e7f1a") + }) + it("turns a code into the uuid range that shares its prefix", async () => { + const { codeRange } = await import("../services/ad-impression.service.js") + expect(codeRange("3f0c2f0e7f1a")).toEqual({ + lo: "3f0c2f0e-7f1a-0000-0000-000000000000", + hi: "3f0c2f0e-7f1a-ffff-ffff-ffffffffffff", + }) + expect(codeRange("3F0C2F0E7F1A")?.lo).toBe("3f0c2f0e-7f1a-0000-0000-000000000000") + }) + it("rejects anything that is not exactly 12 hex chars", async () => { + const { codeRange } = await import("../services/ad-impression.service.js") + expect(codeRange("abc")).toBeNull() + expect(codeRange("zzzzzzzzzzzz")).toBeNull() + expect(codeRange("3f0c2f0e7f1a00")).toBeNull() + expect(codeRange("3f0c2f0e-7f1")).toBeNull() + }) +}) diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts index d448f973..ddb83200 100644 --- a/packages/api/src/app.ts +++ b/packages/api/src/app.ts @@ -33,7 +33,7 @@ import { adminRouter } from "./routes/admin.js" import { testHelpersRouter } from "./routes/__test-helpers.js" import { requireAuth } from "./middleware/auth.js" import { globalLimiter, userLimiter } from "./middleware/rate-limit.js" -import { adsClickRouter } from "./routes/ads-click.js" +import { adsClickRouter, adsShortClickRouter } from "./routes/ads-click.js" import { meEarningsRouter } from "./routes/me-earnings.js" import { getDb } from "./db/index.js" import { users } from "./db/schema/users.js" @@ -75,6 +75,7 @@ app.use(globalLimiter) app.use("/channels", channelsPublicRouter) // public ad click redirect (global limiter applies) app.use("/ads", adsClickRouter) +app.use("/c", adsShortClickRouter) app.use("/tickers", tickersRouter) app.use("/cli", cliVersionRouter) diff --git a/packages/api/src/routes/ads-click.ts b/packages/api/src/routes/ads-click.ts index 65298e82..e5b64d1c 100644 --- a/packages/api/src/routes/ads-click.ts +++ b/packages/api/src/routes/ads-click.ts @@ -1,7 +1,7 @@ import { Router } from "express" import { env } from "../config/env.js" import { logger } from "../lib/logger.js" -import { recordAdClick } from "../services/ad-impression.service.js" +import { recordAdClick, recordAdClickByCode } from "../services/ad-impression.service.js" export const adsClickRouter: ReturnType = Router() @@ -22,3 +22,17 @@ adsClickRouter.get("/click/:deliveryId", async (req, res) => { adsClickRouter.get("/click", (_req, res) => { res.redirect(302, env.webUrl) }) + +// GET /c/:code — the short form printed in the terminal panel +export const adsShortClickRouter: ReturnType = Router() + +adsShortClickRouter.get("/:code", async (req, res) => { + let target: string | null = null + try { + target = await recordAdClickByCode(String(req.params["code"] ?? "")) + } catch (err) { + logger.warn({ err }, "ad short click record failed") + } + res.setHeader("Cache-Control", "no-store") + res.redirect(302, target ?? env.webUrl) +}) diff --git a/packages/api/src/services/ad-impression.service.ts b/packages/api/src/services/ad-impression.service.ts index 73823d2d..b9b9a056 100644 --- a/packages/api/src/services/ad-impression.service.ts +++ b/packages/api/src/services/ad-impression.service.ts @@ -1,4 +1,4 @@ -import { and, eq, sql } from "drizzle-orm" +import { and, eq, gte, lte, sql } from "drizzle-orm" import { MIN_COMPLETED_DURATION_MS, REVENUE_SHARE_DEVELOPER } from "@distrotv/shared" import { getDb } from "../db/index.js" import { adImpressions } from "../db/schema/ad_impressions.js" @@ -18,6 +18,22 @@ export function isDeliveryId(v: string): boolean { return UUID_RE.test(v) } +// short click links: the first 12 hex chars of the delivery id (48 bits) — short +// enough to print in a terminal, where any visible http url is cmd-clickable. +const CODE_RE = /^[0-9a-f]{12}$/ + +export function clickCode(deliveryId: string): string { + return deliveryId.replace(/-/g, "").slice(0, 12).toLowerCase() +} + +// uuids compare bytewise, so a prefix is an index-friendly range on delivery_id +export function codeRange(code: string): { lo: string; hi: string } | null { + const c = code.toLowerCase() + if (!CODE_RE.test(c)) return null + const head = `${c.slice(0, 8)}-${c.slice(8, 12)}` + return { lo: `${head}-0000-0000-000000000000`, hi: `${head}-ffff-ffff-ffffffffffff` } +} + export function parseAdImpression(raw: unknown): AdImpressionInput | null { if (!raw || typeof raw !== "object") return null const r = raw as Record @@ -114,3 +130,18 @@ export async function recordAdClick(deliveryId: string): Promise if (!before.clicked && row.clickBeaconUrl) void fireBeacon(row.clickBeaconUrl) return row.targetUrl } + +// resolves a short code to its delivery and records the click. an ambiguous +// prefix (more than one match) is treated as unknown rather than guessed. +export async function recordAdClickByCode(code: string): Promise { + const range = codeRange(code) + if (!range) return null + const rows = await getDb() + .select({ deliveryId: adImpressions.deliveryId }) + .from(adImpressions) + .where(and(gte(adImpressions.deliveryId, range.lo), lte(adImpressions.deliveryId, range.hi))) + .limit(2) + const only = rows.length === 1 ? rows[0] : undefined + if (!only) return null + return recordAdClick(only.deliveryId) +} diff --git a/packages/api/src/services/ad-supply.service.ts b/packages/api/src/services/ad-supply.service.ts index a1335f02..e76f3566 100644 --- a/packages/api/src/services/ad-supply.service.ts +++ b/packages/api/src/services/ad-supply.service.ts @@ -5,6 +5,7 @@ import { getDb } from "../db/index.js" import { adImpressions } from "../db/schema/ad_impressions.js" import { logger } from "../lib/logger.js" import { displayLabel, fetchCarbonAd } from "./carbon-ad.provider.js" +import { clickCode } from "./ad-impression.service.js" import { houseAd } from "./house-ads.js" let houseCursor = 0 @@ -52,7 +53,8 @@ export async function nextAds(args: { headline: ad.headline, ctaText: ad.ctaText, displayUrl: displayLabel(ad.targetUrl, ad.advertiser), - clickUrl: `${env.apiUrl}/ads/click/${deliveryId}`, + // short form so the terminal can print it as a cmd-clickable url + clickUrl: `${env.apiUrl}/c/${clickCode(deliveryId)}`, deliveryId, cpmRate, })) diff --git a/packages/cli/src/lib/__tests__/render-sponsored.test.ts b/packages/cli/src/lib/__tests__/render-sponsored.test.ts index 66b3ad1f..ab9d269c 100644 --- a/packages/cli/src/lib/__tests__/render-sponsored.test.ts +++ b/packages/cli/src/lib/__tests__/render-sponsored.test.ts @@ -11,7 +11,7 @@ const ad: SponsoredPayload & { cacheSource: "api" } = { headline: "See the error, the commit, and the fix. Monitoring built for developers.", ctaText: "Try Sentry", displayUrl: "sentry.io", - clickUrl: "http://localhost:3011/ads/click/3f0c2f0e-7f1a-4b57-9d9e-0d8f4f3f2a11", + clickUrl: "http://localhost:3011/c/3f0c2f0e7f1a", deliveryId: "3f0c2f0e-7f1a-4b57-9d9e-0d8f4f3f2a11", cpmRate: 10, cacheSource: "api", @@ -27,30 +27,47 @@ describe("sponsored panel", () => { it("estimates the per-impression share", () => { expect(perImpressionUsd(10)).toBeCloseTo(0.007, 6) }) - it("shows label, advertiser, copy, link, command and estimates", () => { + it("shows an AD badge, the advertiser, the copy, a clickable url and the estimates", () => { const out = renderSlotLine(ad, "none", 80, undefined, { earnedTodayUsd: 0.42 }) - expect(out).toContain("sponsored") - expect(out).toContain("via Carbon") - expect(out).toContain("Sentry") - expect(out).toContain("See the error") - expect(out).toContain("sentry.io") - expect(out).toContain("dtv open") - expect(out).toContain("+$0.0070 est") - expect(out).toContain("today $0.4200") + const lines = out.split("\n") + expect(lines).toHaveLength(3) + expect(lines[0]).toContain("AD") + expect(lines[0]).toContain("Sentry") + expect(lines[0]).toContain("+$0.0070") + expect(lines[1]).toContain("See the error") + // the full http url is printed so the terminal itself makes it cmd-clickable + expect(lines[2]).toContain(ad.clickUrl) + expect(lines[2]).toMatch(/click/) + expect(lines[2]).toContain("est. today $0.4200") }) - it("says demo for house ads", () => { - expect(renderSlotLine({ ...ad, source: "house" }, "none", 80)).toContain("via Distro") + it("marks every line with the bar so the block survives stripped indentation", () => { + const lines = renderSlotLine(ad, "none", 80, undefined, { earnedTodayUsd: 1 }).split("\n") + for (const l of lines) expect(l.startsWith("▍ ")).toBe(true) }) - it.each([60, 80, 120])("fits width %i and stays within 6 lines", (w) => { + it("drops the noise: no network label, no command hint", () => { + const out = renderSlotLine({ ...ad, source: "house" }, "none", 80) + expect(out).not.toContain("via ") + expect(out).not.toContain("demo") + expect(out).not.toContain("dtv open") + }) + it("never truncates the url, even when the terminal is narrow", () => { + const out = renderSlotLine(ad, "none", 48, undefined, { earnedTodayUsd: 3 }) + expect(out).toContain(ad.clickUrl) + }) + it("keeps long copy to one line on wide terminals and two on narrow ones", () => { + expect(renderSlotLine(ad, "none", 120).split("\n")).toHaveLength(3) + expect(renderSlotLine(ad, "none", 50).split("\n").length).toBeLessThanOrEqual(4) + }) + it.each([60, 80, 120])("fits width %i and stays within 4 lines", (w) => { const lines = strip( renderSlotLine(ad, "truecolor", w, undefined, { earnedTodayUsd: 1.5, hyperlinks: true }) ).split("\n") - expect(lines.length).toBeLessThanOrEqual(6) + expect(lines.length).toBeLessThanOrEqual(4) for (const l of lines) expect([...l].length).toBeLessThanOrEqual(w) }) it("wraps the link in an OSC 8 hyperlink only when enabled", () => { expect(renderSlotLine(ad, "truecolor", 80, undefined, { hyperlinks: true })).toContain( - "\x1b]8;;http://localhost:3011/ads/click/" + "\x1b]8;;http://localhost:3011/c/" ) expect(renderSlotLine(ad, "truecolor", 80, undefined, { hyperlinks: false })).not.toContain( "\x1b]8;;" diff --git a/packages/cli/src/lib/render-sponsored.ts b/packages/cli/src/lib/render-sponsored.ts index d434bef1..a7a3fdab 100644 --- a/packages/cli/src/lib/render-sponsored.ts +++ b/packages/cli/src/lib/render-sponsored.ts @@ -1,6 +1,6 @@ import { REVENUE_SHARE_DEVELOPER, type SponsoredPayload } from "@distrotv/shared" -import { color, type ColorMode } from "./ansi.js" -import { LEFT_PAD, spread, wrapHeadline } from "./render-utils.js" +import { bgRgb, bold, color, rgb, type ColorMode } from "./ansi.js" +import { spread, visLen, wrapHeadline } from "./render-utils.js" export interface RenderExtras { earnedTodayUsd?: number @@ -29,6 +29,15 @@ function link(text: string, url: string, on: boolean): string { return on ? `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\` : text } +const CLICK_HINT = process.platform === "darwin" ? "⌘ click" : "ctrl click" + +// three rows, every one led by the bar: claude code strips leading whitespace from +// status lines, so indentation can't group the block — the bar does. +// ▍ AD Advertiser +$0.0070 +// ▍ one or two lines of copy +// ▍ ↗ http://…/c/ ⌘ click est. today $0.0770 +// the url is printed in full on purpose: terminals make any visible http url +// cmd/ctrl-clickable, which is the only same-terminal click that needs no key capture. export function renderSponsoredPanel( slot: SponsoredPayload, mode: ColorMode, @@ -36,25 +45,31 @@ export function renderSponsoredPanel( nudge: string[], extras: RenderExtras = {} ): string { - const dot = color("muted", "·", mode) - const via = slot.source === "carbon" ? "via Carbon" : "via Distro · demo" - const headerLeft = `${color("indigo", "▍", mode)} ${color("indigo", "sponsored", mode)} ${dot} ${color("muted", via, mode)}` - const headerRight = color("positive", `+$${perImpressionUsd(slot.cpmRate).toFixed(4)} est`, mode) - const header = spread(headerLeft, headerRight, W) + const bar = `${color("indigo", "▍", mode)} ` + const inner = W - 2 + + const badge = + mode === "none" ? "AD" : bgRgb(rgb(bold(" AD ", mode), 10, 10, 12, mode), 129, 140, 248, mode) + const headLeft = `${badge} ${bold(color("fg", clean(slot.advertiser), mode), mode)}` + const headRight = color("positive", `+$${perImpressionUsd(slot.cpmRate).toFixed(4)}`, mode) + const head = spread(headLeft, headRight, inner) - const advertiser = `${LEFT_PAD}${color("fg", clean(slot.advertiser), mode)}` - const copy = wrapHeadline(clean(slot.headline), W - LEFT_PAD.length, 2).map( - (l) => `${LEFT_PAD}${color("fg", l, mode)}` - ) + const copy = wrapHeadline(clean(slot.headline), inner, 2).map((l) => color("fg", l, mode)) const useLinks = extras.hyperlinks === true && mode !== "none" - const host = link(color("indigo", `↗ ${clean(slot.displayUrl)}`, mode), slot.clickUrl, useLinks) + const url = link(color("indigo", `↗ ${slot.clickUrl}`, mode), slot.clickUrl, useLinks) + const hint = ` ${color("muted", CLICK_HINT, mode)}` const today = extras.earnedTodayUsd != null - ? ` ${dot} ${color("muted", `today $${formatEarned(extras.earnedTodayUsd)}`, mode)}` + ? color("muted", `est. today $${formatEarned(extras.earnedTodayUsd)}`, mode) : "" - // no spread() on this line — OSC 8 bytes would break its width maths - const action = `${LEFT_PAD}${host} ${dot} ${color("muted", "dtv open", mode)}${today}` + // drop the hint, then the total, before ever truncating the url — a cut url can't be clicked + const fits = (left: string, right: string): boolean => + visLen(left) + (right ? 3 + visLen(right) : 0) <= inner + let action: string + if (fits(url + hint, today)) action = today ? spread(url + hint, today, inner) : url + hint + else if (fits(url, today)) action = today ? spread(url, today, inner) : url + else action = url - return [...nudge, header, advertiser, ...copy, action].join("\n") + return [...nudge, ...[head, ...copy, action].map((l) => bar + l)].join("\n") } diff --git a/packages/cli/src/lib/render-utils.ts b/packages/cli/src/lib/render-utils.ts index 1048a340..0d5c11b5 100644 --- a/packages/cli/src/lib/render-utils.ts +++ b/packages/cli/src/lib/render-utils.ts @@ -1,6 +1,7 @@ // shared layout helpers for the status-line panels. -const ANSI_RE = /\x1b\[[0-9;]*m/g +// sgr colour codes and OSC 8 hyperlink wrappers are zero-width +const ANSI_RE = /\x1b\[[0-9;]*m|\x1b\]8;;[^\x1b\x07]*(?:\x1b\\|\x07)/g export const LEFT_PAD = " " export function visLen(s: string): number { From 7abdd1fc6260ea9069a0c6c31234c83a43f51293 Mon Sep 17 00:00:00 2001 From: distroinfinity Date: Sat, 19 Sep 2026 11:30:43 -0700 Subject: [PATCH 13/35] feat(dashboard): revenue page, feeds toggle, overview earnings strip --- frontend/app/dashboard/page.tsx | 122 +++++++++-------- frontend/app/dashboard/revenue/page.tsx | 53 ++++++++ frontend/components/dashboard/app-shell.tsx | 2 + .../dashboard/overview/earnings-strip.tsx | 39 ++++++ .../dashboard/preferences/feeds-row.tsx | 115 ++++++++++++++++ .../preferences/preferences-form.tsx | 8 ++ .../dashboard/revenue/earnings-chart.tsx | 103 +++++++++++++++ .../dashboard/revenue/earnings-hero.tsx | 47 +++++++ .../dashboard/revenue/recent-ads.tsx | 114 ++++++++++++++++ .../dashboard/revenue/stat-tiles.tsx | 41 ++++++ frontend/lib/dashboard-api.ts | 123 ++++-------------- frontend/lib/format.ts | 10 +- 12 files changed, 627 insertions(+), 150 deletions(-) create mode 100644 frontend/app/dashboard/revenue/page.tsx create mode 100644 frontend/components/dashboard/overview/earnings-strip.tsx create mode 100644 frontend/components/dashboard/preferences/feeds-row.tsx create mode 100644 frontend/components/dashboard/revenue/earnings-chart.tsx create mode 100644 frontend/components/dashboard/revenue/earnings-hero.tsx create mode 100644 frontend/components/dashboard/revenue/recent-ads.tsx create mode 100644 frontend/components/dashboard/revenue/stat-tiles.tsx diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx index 6ac26b2e..2baf441e 100644 --- a/frontend/app/dashboard/page.tsx +++ b/frontend/app/dashboard/page.tsx @@ -12,7 +12,8 @@ import type { } from "@distrotv/shared" import { OverviewClient } from "@/components/dashboard/overview/overview-client" import type { NewsRowData } from "@/components/dashboard/overview/news-row" -import type { ReadingListResponse } from "@/lib/dashboard-api" +import { EarningsStrip } from "@/components/dashboard/overview/earnings-strip" +import type { EarningsSummary, ReadingListResponse } from "@/lib/dashboard-api" interface PrefsPayload { preferences: SyncedPreferences @@ -28,59 +29,74 @@ export default async function DashboardOverview() { if (!session || !token) redirect("/sign-in?next=/dashboard") - const [prefs, channels, watchlists, , summary, sparklines, recentNews, reading, alertEvents] = - await Promise.all([ - apiFetchOrRefresh("/me/preferences", "/dashboard").catch( - (): PrefsPayload => ({ preferences: {} as SyncedPreferences }) - ), - apiFetchOrRefresh<{ channels: ChannelDto[] }>("/me/channels", "/dashboard").catch(() => ({ - channels: [] as ChannelDto[], - })), - apiFetchOrRefresh<{ watchlists: WatchlistDto[] }>("/me/watchlists", "/dashboard").catch( - () => ({ watchlists: [] as WatchlistDto[] }) - ), - apiFetchOrRefresh<{ alerts: AlertDto[] }>("/me/alerts", "/dashboard").catch(() => ({ - alerts: [] as AlertDto[], - })), - apiFetchOrRefresh( - "/me/activity-summary?windowSec=86400", - "/dashboard" - ).catch( - (): ActivitySummaryDto => ({ - windowSec: 86400, - events: [], - totals: { news: 0, ticker: 0, alert: 0, uptime_days: 0 }, - }) - ), - apiFetchOrRefresh<{ sparklines: SparklineDto[] }>( - "/me/watchlist/sparklines?windowSec=86400", - "/dashboard" - ).catch(() => ({ sparklines: [] as SparklineDto[] })), - apiFetchOrRefresh("/me/recent-news?limit=25", "/dashboard").catch(() => ({ - items: [] as NewsRowData[], - })), - apiFetchOrRefresh("/me/reading?limit=100", "/dashboard").catch(() => ({ - items: [], - hasMore: false, - })), - apiFetchOrRefresh<{ events: AlertEventDto[] }>( - "/me/alerts/events?limit=25", - "/dashboard" - ).catch(() => ({ events: [] as AlertEventDto[] })), - ]) + const [ + prefs, + channels, + watchlists, + , + summary, + sparklines, + recentNews, + reading, + alertEvents, + earnings, + ] = await Promise.all([ + apiFetchOrRefresh("/me/preferences", "/dashboard").catch( + (): PrefsPayload => ({ preferences: {} as SyncedPreferences }) + ), + apiFetchOrRefresh<{ channels: ChannelDto[] }>("/me/channels", "/dashboard").catch(() => ({ + channels: [] as ChannelDto[], + })), + apiFetchOrRefresh<{ watchlists: WatchlistDto[] }>("/me/watchlists", "/dashboard").catch(() => ({ + watchlists: [] as WatchlistDto[], + })), + apiFetchOrRefresh<{ alerts: AlertDto[] }>("/me/alerts", "/dashboard").catch(() => ({ + alerts: [] as AlertDto[], + })), + apiFetchOrRefresh( + "/me/activity-summary?windowSec=86400", + "/dashboard" + ).catch( + (): ActivitySummaryDto => ({ + windowSec: 86400, + events: [], + totals: { news: 0, ticker: 0, alert: 0, uptime_days: 0 }, + }) + ), + apiFetchOrRefresh<{ sparklines: SparklineDto[] }>( + "/me/watchlist/sparklines?windowSec=86400", + "/dashboard" + ).catch(() => ({ sparklines: [] as SparklineDto[] })), + apiFetchOrRefresh("/me/recent-news?limit=25", "/dashboard").catch(() => ({ + items: [] as NewsRowData[], + })), + apiFetchOrRefresh("/me/reading?limit=100", "/dashboard").catch(() => ({ + items: [], + hasMore: false, + })), + apiFetchOrRefresh<{ events: AlertEventDto[] }>( + "/me/alerts/events?limit=25", + "/dashboard" + ).catch(() => ({ events: [] as AlertEventDto[] })), + // an earnings failure renders no strip — it never breaks the overview + apiFetchOrRefresh("/me/earnings/summary", "/dashboard").catch(() => null), + ]) return ( - + <> + {earnings && } + + ) } diff --git a/frontend/app/dashboard/revenue/page.tsx b/frontend/app/dashboard/revenue/page.tsx new file mode 100644 index 00000000..5d0ba88e --- /dev/null +++ b/frontend/app/dashboard/revenue/page.tsx @@ -0,0 +1,53 @@ +import { BlurFade } from "@distrotv/design-system/components/blur-fade" +import { apiFetchOrRefresh } from "@/lib/api" +import type { EarningsPoint, EarningsSummary, RecentAd } from "@/lib/dashboard-api" +import { EarningsHero } from "@/components/dashboard/revenue/earnings-hero" +import { StatTiles } from "@/components/dashboard/revenue/stat-tiles" +import { EarningsChart } from "@/components/dashboard/revenue/earnings-chart" +import { RecentAds } from "@/components/dashboard/revenue/recent-ads" + +export const dynamic = "force-dynamic" + +export default async function RevenuePage() { + const back = "/dashboard/revenue" + const [summary, series, recent] = await Promise.all([ + apiFetchOrRefresh("/me/earnings/summary", back), + apiFetchOrRefresh<{ points: EarningsPoint[] }>("/me/earnings/timeseries?days=30", back), + apiFetchOrRefresh<{ items: RecentAd[] }>("/me/earnings/recent?limit=20", back), + ]) + const empty = summary.impressions === 0 && summary.clicks === 0 + + return ( +
+ + + + + {empty ? ( + +
+

+ no ads seen yet +

+

+ Start a Claude Code session and let your agent work — sponsored slots appear in the + status line, and your estimated share shows up here. +

+
+
+ ) : ( + <> + + + + + + + + + + + )} +
+ ) +} diff --git a/frontend/components/dashboard/app-shell.tsx b/frontend/components/dashboard/app-shell.tsx index 41b02238..cfaa52f0 100644 --- a/frontend/components/dashboard/app-shell.tsx +++ b/frontend/components/dashboard/app-shell.tsx @@ -19,6 +19,7 @@ export function AppShell({ user, initialMode, children, configReadout }: AppShel const pills = ( <> + @@ -44,6 +45,7 @@ export function AppShell({ user, initialMode, children, configReadout }: AppShel