diff --git a/.env.example b/.env.example index c72cb96..49a2e6a 100644 --- a/.env.example +++ b/.env.example @@ -48,6 +48,14 @@ MAIL_FROM="Adex " # openssl rand -hex 32 CRON_SECRET="" +# Growth ingest secrets (org-level PlatformAuth rows override these fallbacks). +# INGEST_WEBHOOK_SECRET — HMAC signing key for POST /api/ingest/events. +# INGEST_ADJUST_SECRET — static token for GET|POST /api/ingest/adjust. MUST be +# distinct from INGEST_WEBHOOK_SECRET: the Adjust token travels in cleartext +# query strings (lands in access logs) and must not unlock HMAC forgery. +INGEST_WEBHOOK_SECRET="" +INGEST_ADJUST_SECRET="" + # Demo seed config (used by `npm run db:seed`). # - SEED_EMAIL: defaults to demo@adexads.com if unset. # - SEED_PASSWORD: if unset, the seed script generates a cryptographically diff --git a/.gitignore b/.gitignore index 3e265c7..6057e4b 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,8 @@ dev.db # Local Claude Code preview server config .claude/launch.json + +# Local-only design/reference docs — business material, never commit (see +# ~/.claude data-handling rules). Keep such files out of the repo tree. +docs/adex-google-ads-api-design-document.pdf +docs/**/*.pdf diff --git a/docs/growth/04-status.md b/docs/growth/04-status.md index f675e08..64c35c1 100644 --- a/docs/growth/04-status.md +++ b/docs/growth/04-status.md @@ -7,7 +7,7 @@ Three stacked PRs — **merge in order 1 → 2 → 3**: | PR | Scope | Branch | -|---|---|---| +| --- | --- | --- | | [#1](https://github.com/oratis/adex/pull/1) | Growth OS P18–P19 + loopback design language | `feat/growth-cuddler-pilot` → `main` | | [#2](https://github.com/oratis/adex/pull/2) | Creative Studio (物料 capability) | `feat/growth-creative-studio` → #1 | | [#3](https://github.com/oratis/adex/pull/3) | P21 Growth Agent | `feat/growth-agent-p21` → #2 | @@ -21,7 +21,7 @@ Every decision/attribution/spec/validation module is pure + unit-tested under `s Each item's pure logic is done and covered by tests; what's left is wiring the credentialed call. | Seam | Decision layer ready | Needs | -|---|---|---| +| --- | --- | --- | | **Adapter app-install writes** — real `OUTCOME_APP_PROMOTION` / `APP_PROMOTION` createCampaign | `campaign-objective.ts` (resolve + validate) + launch-route gate | Meta/TikTok/Google sandbox creds; adapter `launchCampaign` app-promotion branch | | **Creative render** — variant → actual Seedance2/Seedream job → transcode/crop → platform push | `storyboard.ts`, `creative-specs.ts` (validate/needs_transcode), `studio/produce` (creates the Creative + prompt) | `SEEDANCE2_API_KEY` / Seedream; a transcode worker | | **GA4 event-level pull** | `ga4.ts` mapper + `ingest/events` push (the real-time path) | GA4 **BigQuery export** for user-level events (Data API is aggregate-only) — or rely on the push channel | @@ -35,3 +35,14 @@ Each item's pure logic is done and covered by tests; what's left is wiring the c ## Suggested next milestone Once a platform sandbox + `SEEDANCE2_API_KEY` are available: wire the adapter app-install branch + the produce→render→push loop end-to-end against a test ad account — the first real $5K-pilot campaign created entirely inside Adex. + +## 2026-07-07 · fork contributions (PR #4–#7, stacked, merge in order) + +| PR | Scope | Key change | +| --- | --- | --- | +| [#4](https://github.com/oratis/adex/pull/4) | agent runtime | The four pilot disciplines wired as real guardrail evaluators; `executeApprovedDecision` re-checks guardrails at execution time (bypass closed); growth snapshot now rendered into the plan prompt | +| [#5](https://github.com/oratis/adex/pull/5) | data ingest | Adjust S2S callback → ConversionEvent with single install-source authority (anti-double-count), `ADJUST_NETWORK_MAP` channel mapping, namespaced userKey | +| [#6](https://github.com/oratis/adex/pull/6) | canon + BI | Signup-anchored cohorts (authority never filters signup), os/agency dims, D0/D7 revenue windows, spend injection (CAC no longer null), mature-window retention gating, `/api/growth/summary` + `/api/reports/breakdown`, dashboard BI view | +| [#7](https://github.com/oratis/adex/pull/7) | attribution | Campaign-name canon (`agency-date-bid-os-regions-channel-...`) parsed into agency/bidStrategy/conversionGoal; funnel↔delivery join on `(date, os, platform, agency)` | + +Pipeline canon lives in [06-mmp-ingest.md](06-mmp-ingest.md) §6–§7. Ops prerequisites for live data: Adjust callback + `app_user_id` **callback parameter** (not partner parameter), backend pushes signup/payment with `source=backend`, campaign naming discipline from day one. diff --git a/docs/growth/06-mmp-ingest.md b/docs/growth/06-mmp-ingest.md new file mode 100644 index 0000000..603e58c --- /dev/null +++ b/docs/growth/06-mmp-ingest.md @@ -0,0 +1,136 @@ +# MMP(Adjust / AppsFlyer)接入新数据链路指南 + +> 版本 v1 · 2026-07-07 · 结论先行:**MMP 走 S2S callback → ConversionEvent;接之前必须先堵三个洞,否则 GA4 + MMP 双开会让 install 翻倍、CAC 腰斩,且现有测试测不出来。** + +## 0. 现状:MMP 只喂旧链路 + +`src/lib/platforms/adjust.ts` / `appsflyer.ts` 目前只做一件事:`reports/sync` 拉 app 级日聚合(Adjust 维度仅 `day,app`,无渠道拆分)→ 写 `Report(level=account)` → `/dashboard` 展示。**这条旧链路保留不动**——它是"MMP 原生总量视图",与新链路(ConversionEvent → CohortSnapshot → `/growth` + agent perceive)物理隔离(读写零交集),二者数字对不上是口径差异,不是 bug,不要试图对齐。 + +真正的冲突全在新链路内部:让 MMP 往 ConversionEvent 写数据时,它会和 GA4 在同一张 CohortSnapshot 里打架。 + +## 1. 三个必堵的洞(接入前置,缺一不可) + +| # | 洞 | 成因 | 后果 | 堵法 | +| --- | --- | --- | --- | --- | +| 1 | **双源 install 重复计数** | 幂等键 `@@unique([orgId, source, eventName, userKey, occurredAt])` 把 `source` 算进唯一约束(GA4/Adjust 各落一行),而 `cohorts.ts` 聚合完全不感知 source、只按 userKey 分组累加 | installs 翻倍 → CAC 腰斩 → gate 判定(放量/CAC 上限)资损级误判 | `RawEvent` 加 `source` 字段;cohort 聚合前按 org 配置的 install 权威源单选(见 §2 决策 A) | +| 2 | **channel 归一化失配** | `channels.ts:resolveChannel` 只认 `adex_*` UTM 和 kol/seo 等裸码;MMP 的 network 名("Apple Search Ads"、"Meta Installs")全部兜底成 `organic` | 同一渠道在 CohortSnapshot 裂成两行(如 ASA 裂成 `organic` + `paid_asa`),看板和 gate 各算各的 | 新增 `ADJUST_NETWORK_MAP` 显式映射表 + `resolveAdjustChannel()`;**不要扩 `resolveChannel`**(它服务自有 UTM 主路径) | +| 3 | **userKey 跨源断裂** | 留存 D1/D7 = "同一 userKey 在 +1/+7 有 GA4 行为事件";RC 收入也靠 userKey join。MMP 的 adid/idfa 与 GA4 pseudo_id、RC app_user_id 是不同命名空间 | MMP 渠道的 cohort **留存和 LTV 假性归零**;同人被当两个 user 重复计 install | 首选:callback 透传 RC `app_user_id` 统一 userKey(需客户侧配置,Adjust callback parameter 支持,SDK v5 addGlobalCallbackParameter,v4 addSessionCallbackParameter);降级:userKey 加 `adjust:` 前缀、承认跨源不 join(见 §2 决策 B) | + +## 2. 两个口径决策(已拍板) + +- **决策 A · install 权威源**(已拍板):接了 Adjust 的 org,install 与渠道归因以 `source='adjust'` 为权威;GA4 只供 first_chat / scene_generated 等 Adjust 不报的漏斗深层事件。实现:`kpi-canon.ts:resolveInstallAuthority({ hasAdjustAuth, adjustInstallCount, ga4InstallCount })`——org 存在 `PlatformAuth(platform='adjust')` → `'adjust'`,否则 `'ga4'`。**防归零保险**:若权威源在计算窗口内 install 数为 0 而另一源 > 0(例如 org 只配了 legacy Report 凭证、没接 `/api/ingest/adjust` callback),本次计算回退另一源,并在返回值 `fallback`/`warning` 字段标注,`growth-sync` cron 会把 warning 打进日志。`cohorts.ts:buildCohortSnapshots` 的 `opts.installAuthority` 据此只过滤 ACQUISITION 类事件(install/signup)的来源;漏斗深层/收入事件不受影响。 +- **决策 B · userKey 统一方式**(已拍板):userKey 首选 Adjust callback 透传的 RC `app_user_id`(**callback parameter**——注意不是 partner parameter:partner params 转发给广告网络,callback params 才回到自有 raw-data callback;键名约定 app_user_id,客户端注册成功后经 SDK 设置。对标 AppsFlyer 的 CUID/customer_user_id);取不到时降级为 `adjust:${adid}` 前缀,明确承认这条 userKey 跨源不可 join——留存/LTV 仍以 GA4/RC 体系计算,Adjust 只贡献 install 计数与渠道校正。实现见 `adjust-ingest.ts:mapAdjustCallback`。 + +## 3. 接入步骤(Adjust,S2S callback 方案) + +**不推荐**用 Report Service API 拉聚合造合成事件——聚合行没有 userKey,进不了 cohort。 + +1. **堵洞(代码前置)**——已完成 + - `src/lib/growth/cohorts.ts`:`RawEvent` 加 `source`;`buildCohortSnapshots(events, { installAuthority })` 聚合前按决策 A 单源过滤 ACQUISITION 类事件(install/signup),漏斗深层/收入事件不过滤;`growth-sync` cron 查询同步加 `source` select + - `src/lib/growth/channels.ts`:新增 `ADJUST_NETWORK_MAP: Record` + `resolveAdjustChannel(networkName, campaignName)`。ASA 确定性映射;Meta/TikTok 的 install network 名(`Facebook Installs` / `TikTok Installs` 等)保守映射到 `*_ios`(SKAN,低置信度)——Adjust 的 network_name 分不清 web 与 app-install,`campaign_name` 含 "web" 时才改判 `*_web`;未映射网络名 → `organic`/`inferred`。未改动 `resolveChannel` 一行 + - `src/lib/growth/adjust-ingest.ts`(新建 + 测试):`mapAdjustCallback(params, eventTokenMap?) → ConversionEventInput | null`,照 `ga4.ts:mapGa4Event` 的写法;`activity_kind='install'` → `install`;`='event'` 时查 `eventTokenMap`(event token → canonical `EventName`,映射不到 → drop);`reattribution`/`session` 等 → drop(返回 null,不抛错) + - 测试含**双源用例**(`cohorts.test.ts`):同一真实安装的 GA4 + Adjust 两行(不同 userKey namespace)不设权威时 installs=2(复现洞 1);设 `installAuthority: 'adjust'` 后 installs=1 且渠道以 Adjust 行为准;另有用例确认 installAuthority 不过滤漏斗深层事件 +2. **鉴权路由**——已完成,选定方案一 + - `GET|POST /api/ingest/adjust?org=`(`src/app/api/ingest/adjust/route.ts`),静态密钥 + `verifyBearer`(仿 `src/app/api/ingest/revenuecat/route.ts`)。**密钥槽独立**:存 `PlatformAuth(orgId, platform='ingest_adjust').apiKey`,env `INGEST_ADJUST_SECRET` 兜底——不与 `/api/ingest/events` 的 HMAC 密钥共用,因为 Adjust token 走 `?token=` 明文 query 会进访问日志,泄漏不能连带 HMAC 伪造。同一行的 `extra` JSON 配置 **event token 映射**:`{"eventTokenMap":{"":"trial_start"}}`——不配置时非 install 事件会被安全丢弃(200 ignored),install 不受影响;Adjust 侧密钥支持 `Authorization: Bearer` 或 `?token=` query(callback 是 URL 模板,设不了自定义 header);单条映射失败跳过(200 ok, ignored),不 5xx;写入沿用 `createMany` + `skipDuplicates` 幂等 +3. **Adjust 后台配置**:Raw Data Exports → Real-time callbacks,install + 关键 event 各配一条,URL 带 placeholder:`{activity_kind}`、`{network_name}`、`{campaign_name}`、`{adid}`、`{idfa}`、`{created_at}`、`{country}`,及透传的 callback parameter(决策 B 的 app_user_id;placeholder 具体写法配置时以 Adjust 后台文档核对) +4. **验证清单**:同一 callback 重放两次只落一行(幂等键);ASA/Meta 的 network 名映射进正确 channel(不落 organic);GA4+Adjust 双开的 org 在 `/growth` 的 installs 与单开 MMP 一致;`/dashboard` 的 Report.adjust 总量不受影响 + +## 4. AppsFlyer 附注 + +同构接入:Push API(S2S 实时回传)对应 Adjust callback,`pid/c` 字段对应 `network_name/campaign_name`(需 `APPSFLYER_NETWORK_MAP`),设备标识 `appsflyer_id` 同样是独立命名空间——三个洞和两个决策**一字不差地适用**。旧链路 `getInstallReport`(`groupings=date,pid`)同样保留不动。AppsFlyer 接入尚未实现,本节仍是规划。 + +## 5. 涉及文件速查 + +| 动作 | 文件 | 状态 | +| --- | --- | --- | +| 新建 mapper | `src/lib/growth/adjust-ingest.ts`(+ `adjust-ingest.test.ts`) | 已完成 | +| 新建路由 | `src/app/api/ingest/adjust/route.ts`(GET+POST) | 已完成 | +| 改:channel 映射 | `src/lib/growth/channels.ts`(`ADJUST_NETWORK_MAP` / `resolveAdjustChannel`,+ 测试) | 已完成 | +| 改:单源聚合 | `src/lib/growth/cohorts.ts`、`src/app/api/cron/growth-sync/route.ts`(+ 测试) | 已完成 | +| 改:口径记录 | `src/lib/growth/kpi-canon.ts`(`resolveInstallAuthority`,+ 测试)、本文件 §2 | 已完成 | +| 不动 | `src/lib/platforms/adjust.ts` / `appsflyer.ts`、`reports/sync`、`/api/ingest/events` 鉴权、`ingest-parse.ts`(`source='adjust'` 已注册) | 按计划不动 | +| 未实现 | AppsFlyer S2S 接入(§4 仍是规划,非本次范围) | 待办 | + +## 6. 注册锚点与 BI 口径(已拍板) + +新增 `os`(ios/android/web)与分端 BI 视图,逼着我们把"获客锚点到底是谁"钉死——之前 cohort 锚点隐含"install 优先",signup 只是 install 缺失时的兜底;现在反过来:**signup 优先**,因为它是我们自己的一方事件,不参与 GA4/MMP 双源撞车问题。 + +- **获客锚点**:用户的 cohort 锚点 = 该用户**首个 signup 事件**(任意 source,`installAuthority` 从不过滤 signup——它只管 install 候选集)。若用户没有 signup,退回其**首个满足 installAuthority 的 install 事件**作为锚点;两者都没有则该用户不进任何 cohort。锚点所在的 UTC 自然日 = cohort 日(`cohorts.ts:dayKey`)。 +- **channel / os 归因**:一旦锚定,channel 和 os 优先取该用户"任意一条满足 installAuthority 的 install 事件"的值(MMP 归因通常比 signup 自带的 UTM 更可信),即便锚点本身是 signup。只有当用户完全没有合格的 install 事件时才退回 signup 自带的 channel/os(典型场景:纯 web 注册,从未触发任何 MMP install postback)。 +- **付费与收入窗口**:付费事件 = `subscription_activated`(不含 `renewal`)。`revenueD0` = 锚点日当天(`dayDiff(cohortDate, occurredAt) <= 0`)的 `subscription_activated` 收入;`revenueD7` = 锚点日起 7 天内(`dayDiff <= 7`)的同类收入。二者都是 `revenueToDate`(无时间上限、含 renewal)的子集,不是替代。 +- **D1/D7 完整区间 gate(读侧)**:一个 cohort 的 `cohortDate + N`(UTC)尚未到达"现在"时,它的 D_N 天生结构性为 0——不是"没留存",是"还没到判定的那一天"。因此 `overview` / `channels` / `summary` 三个读侧路由在聚合 D_N 率之前,用 `kpi-canon.isMatureForRetentionWindow(cohortDate, N, now)` 过滤:不成熟的 cohort 既不进分子也不进分母。这修正了旧版 `overview` 把"太年轻"的 cohort 混进 D7 分母、把汇总 D7 率稀释偏低的 bug。 +- **来源=付费/自然**:由 `channels.ts:isPaidChannel(channel)` 推导,不是单独存的字段。 +- **OS 归一**:`ios | android | web` 三值(`events.ts:isOs`),前端展示层可以把 `web` 渲成 "PC",但存储/聚合层统一叫 `web`。各连接器的推导方式: + - Adjust(`adjust-ingest.ts:normalizeAdjustOs`):`os_name=ios/android` 直接映射;`os_name` 缺失时若 `device_type=web` 则判 web;其余留 `null`(不猜)。 + - RevenueCat(`revenuecat.ts:osFromStore`):`store` 字段保守映射——`app_store→ios`、`play_store→android`、`stripe`/`rc_billing→web`;`amazon`/`promotional`/未知 store 留 `null`。 + - `/api/ingest/events`(`ingest-parse.ts`):只信显式传入的 `os` 字段,且必须是合法值,否则丢弃为 `null`,不做推断。 +- **cohort 计数字段**:`CohortSnapshot.installs` 与 `.signups` 现在互斥——同一用户只落在其中一个桶(按锚点类型),`installs + signups` = 该行的锚定用户总数(对内部路由统称"cohortSize")。旧版只有 `installs` 一个计数,语义等价于现在的 `installs + signups`——所有下游路由(`overview`/`channels`/`cohorts`/`summary`)都已改为用 `installs + signups` 作为速率分母。 +- **spend → cohort 归因(growth-sync cron)**:CAC 现在真正非 null 了——cron 按 `Report.platform → 保守 channel 映射`(`google→paid_google_uac`、`meta→paid_meta_web`、`tiktok→paid_tiktok_web`;`adjust`/`appsflyer`/`amazon`/`linkedin` 不映射,spend 计入 `summary[].unallocatedSpend`,不瞎猜)聚合 `${date}|${channel}` 花费,喂给 `buildCohortSnapshots(events, { spendByCohort })`。 +- **Report.agency / PlatformAccount.agency**:`reports/sync` 在写 appsflyer/adjust/amazon/linkedin 的 Report 行时,读取对应平台的 `PlatformAccount(orgId, platform, isPrimary=true).agency` 盖章;没有 `PlatformAccount` 行或没设 `agency` 就留 `null`,不猜。adaptor 驱动的 google/meta/tiktok 写入路径(`report-writer.ts`)本次未接入 agency 盖章——留作后续,明确不在本次范围内。**已被 §7 取代**:campaign 级 Report 行现在从 campaign 名解析 agency,`resolveReportAgency`(PlatformAccount 兜底)降级为 fallback。 +- **`/api/reports/breakdown` 的 funnel 列**:Report 表没有可以 join 回 CohortSnapshot 的 channel/cohort key,这次只输出投放明细(impressions/clicks/spend/cpc),funnel 相关列固定返回 `null` 且 `funnelJoin: 'pending'`,避免被误读成"已 join、恰好是 0"。**已被 §7 取代**:join key 补上了(channel→platform 桥),funnel 列现在按行填实数。 + +## 7. campaign 命名 canon 与归因桥(已拍板) + +`/api/reports/breakdown` 的 funnel 列在 §6 里是硬编码 `null`/`'pending'`——Report 表当时没有能 join 回 CohortSnapshot 的 key。这一节把 join key 补上,并把"代理商/出价方式/转化目标"这三个维度的权威来源从"没有"变成"campaign 命名规范"。 + +### 7.1 数据链路 canon(已拍板) + +- **激活** = Adjust(决策 A 的 install authority,不变)。 +- **注册/留存/付费** = 后端一方事件(signup 锚点 + GA4/RevenueCat,§6 不变)。 +- **代理商 / 出价方式 / 转化目标** = 从 campaign 名解析,**不**依赖任何平台 API 字段——这三者是投放团队自己的业务约定,平台 API 不报告,只有 campaign 命名里编码了。 +- **OS / 渠道** = 以 MMP(Adjust)自有字段为准(`os_name`/`device_type`、`network_name`);campaign 名里解析出的 os 仅当 Adjust 字段缺失时才作为 fallback;渠道**从不**用 campaign 名覆盖(`resolveAdjustChannel` 的 `network_name` 优先级不变,`channelHint` 只是自由文本,不解析进 Channel 枚举)。 +- **前后端串联** = 注册后回传 Adjust 的用户 id——即现有的 RC `app_user_id` callback parameter 机制(决策 B,§2/§3 已有),本节不引入新机制。 + +### 7.2 campaign 命名规范 + +`-` 分段,位置即语义(不足的段留 `null`,超出第 10 段的都归入 `custom`): + +| 位置 | 字段 | 归一规则 | 示例 | +| --- | --- | --- | --- | +| 1 | 代理商 agency | lowercase,无枚举白名单(自定义词表) | `inhouse` | +| 2 | 时间 date | 校验 8 位数字 `YYYYMMDD`,不合法则该字段 `null`(原文保留在 `dateRaw`) | `20260512` | +| 3 | 出价方式 bidStrategy | lowercase,无枚举白名单 | `mai` | +| 4 | OS os | 归一 `ios\|android\|web`(大小写不敏感),认不出则 `null`(原文保留在 `osRaw`) | `Android` → `android` | +| 5 | 地区 regions | 按 `/` 拆成数组,可多值 | `US/T1/JP` → `["US","T1","JP"]` | +| 6 | 渠道提示 channelHint | 原文保留,**不**解析进 Channel 枚举(真正的渠道归因仍是 `resolveAdjustChannel` 走 `network_name`) | `Google` | +| 7 | 编号 index | 原文保留 | `01` | +| 8 | 产品名 product | 原文保留 | `Luddi` | +| 9 | 转化目标 goal | lowercase,无枚举白名单 | `install` | +| 10 | 人群定向 audience | 原文保留 | `female` | +| 11+ | 自定义 custom | 原文保留,按序放入数组 | `Davis-xx` → `["Davis","xx"]` | + +完整例子:`inhouse-20260512-mai-Android-US/T1/JP-Google-01-Luddi-install-female-Davis-xx` + +解析器:`src/lib/growth/campaign-name.ts:parseCampaignName`(纯函数,任何输入不抛错;空串/无 `-` 的名字/非字符串 → `null`)。 + +### 7.3 agency 优先级链 + +`ConversionEvent.agency` / `CohortSnapshot.agency`(Adjust 路径): + +1. `parseCampaignName(campaign_name).agency` +2. 缺失或解析失败 → `null`(不猜) + +`Report.agency`(campaign 级行,`report-writer.ts:writeCampaignReports`): + +1. `parseCampaignName(row.campaignName).agency`(每行按各自的 campaign 名解析,最具体) +2. campaign 名缺失/不解析 → `resolveReportAgency(orgId, platform)`(`PlatformAccount.agency`,org/platform 级兜底,§6 原有实现,现降级为 fallback) +3. 两者都没有 → `null` + +`bidStrategy` / `conversionGoal` 只在 `ConversionEvent`(Adjust 路径)落地,不在 Report 上——Report 是媒介消耗表,这两个维度属于归因/转化语义,不属于投放消耗语义。 + +### 7.4 funnel↔投放桥的 join key + +`/api/reports/breakdown` 现在把 Report 行和 CohortSnapshot 行按 `(date, os, platform, agency)` 四元组 join: + +- Report 侧:`date`/`os`/`platform`/`agency` 直接是列值。 +- CohortSnapshot 侧:`channel` 先经 `channels.ts:channelToPlatform()` 映射成 Report 用的平台字符串(`paid_google_uac→google`、`paid_meta_web`/`paid_meta_ios→meta`、`paid_tiktok_web`/`paid_tiktok_ios→tiktok`、`paid_asa→apple_search_ads`;`organic`/`kol`/`referral`/`seo`/`aso` 等 earned 渠道 → `null`,永不参与 join,不是"join 到 0")。 +- 聚合公式(signups/costPerSignup/d1Rate/d7Rate/d0Roi/d7Roi)复用 `kpi-canon.ts` 新增的 `aggregateCohortWindow`(D1/D7 成熟度 gate,同 §6 的 `isMatureForRetentionWindow`)+ `computeFunnelMetrics`(costPerSignup/roi 公式)——`/api/growth/summary` 与 `/api/reports/breakdown` 现在共用这两个纯函数,避免两个视图的公式各自漂移。 +- 响应级 `funnelJoin: 'full' | 'partial' | 'none'`(不是逐行字段):按 Report 侧分组的 join 命中率算,`'full'` = 全部分组都 join 上,`'none'` = 一个都没 join 上(含没有 Report 行的平凡情形),否则 `'partial'`。前端仅在 `funnelJoin !== 'full'` 时显示表头的"pending"提示。 +- 单行 join 不上时,该行 funnel 列(signups/costPerSignup/d1Rate/d7Rate/d0Roi/d7Roi)固定 `null`——不是 0,避免"join 到 0"和"没 join"两种情况在 UI 上分不清。 + +### 7.5 已知限制 + +- campaign 名不遵守本规范(段数不够、非法日期、认不出的 OS 文本等)的行,对应字段留 `null`,不会报错也不会被丢弃——只是拿不到那个维度。 +- `channelHint`(campaign 名第 6 段)是自由文本展示字段,**不**参与任何归因判定;真正决定 Channel 枚举的仍然只有 `resolveAdjustChannel` 的 `network_name`(+ campaign 名里的"web"关键字 fallback,§1 洞 2 的既有逻辑,不受本节影响)。 +- adapter 驱动的账户级 Report 行(`report-writer.ts:writeAccountReport`,google/meta/tiktok 的 account-level 汇总)本次仍未接入 agency 盖章——`writeAccountReport` 没有单条 campaign 名可解析,仍是 §6 遗留的范围外项。 +- Report 侧的 `os` 列对 adapter 驱动的行(google/meta/tiktok campaign/account 级)目前始终是 `null`(没有任何写入路径为它们推导 os)——这意味着这些平台的 breakdown 行只能在 CohortSnapshot 的 os 也是 `null` 时才 join 得上,日常会明显拉低这些平台的 join 命中率。这是现有数据模型的限制,不是本次改动引入的新问题;若要提高命中率,需要先给 adapter 驱动的 Report 行接入 os 推导(超出本次范围)。 diff --git a/prisma/migrations/20260707120000_bi_breakdown/migration.sql b/prisma/migrations/20260707120000_bi_breakdown/migration.sql new file mode 100644 index 0000000..baba456 --- /dev/null +++ b/prisma/migrations/20260707120000_bi_breakdown/migration.sql @@ -0,0 +1,25 @@ +-- BI breakdown (docs/growth/06-mmp-ingest.md §6) — os dimension + signup-anchored +-- cohort fields + agency dimension on Report/PlatformAccount. Additive only. + +-- AlterTable +ALTER TABLE "ConversionEvent" ADD COLUMN "os" TEXT; + +-- AlterTable +ALTER TABLE "Report" ADD COLUMN "os" TEXT; +ALTER TABLE "Report" ADD COLUMN "agency" TEXT; + +-- AlterTable +ALTER TABLE "PlatformAccount" ADD COLUMN "agency" TEXT; + +-- AlterTable +ALTER TABLE "CohortSnapshot" ADD COLUMN "os" TEXT; +ALTER TABLE "CohortSnapshot" ADD COLUMN "signups" INTEGER NOT NULL DEFAULT 0; +ALTER TABLE "CohortSnapshot" ADD COLUMN "revenueD0" DOUBLE PRECISION NOT NULL DEFAULT 0; +ALTER TABLE "CohortSnapshot" ADD COLUMN "revenueD7" DOUBLE PRECISION NOT NULL DEFAULT 0; + +-- DropIndex (widen unique key to include os — see schema.prisma CohortSnapshot +-- doc comment for the Postgres NULL-uniqueness caveat this inherits) +DROP INDEX "CohortSnapshot_orgId_appId_cohortDate_channel_key"; + +-- CreateIndex +CREATE UNIQUE INDEX "CohortSnapshot_orgId_appId_cohortDate_channel_os_key" ON "CohortSnapshot"("orgId", "appId", "cohortDate", "channel", "os"); diff --git a/prisma/migrations/20260707130000_campaign_name_canon/migration.sql b/prisma/migrations/20260707130000_campaign_name_canon/migration.sql new file mode 100644 index 0000000..3d406cd --- /dev/null +++ b/prisma/migrations/20260707130000_campaign_name_canon/migration.sql @@ -0,0 +1,20 @@ +-- Campaign-name canon (docs/growth/06-mmp-ingest.md §7) — agency / bid +-- strategy / conversion goal parsed from campaign_name, plus the agency +-- dimension on CohortSnapshot needed to join cohort attribution back to +-- Report spend by agency. Additive only. + +-- AlterTable +ALTER TABLE "ConversionEvent" ADD COLUMN "agency" TEXT; +ALTER TABLE "ConversionEvent" ADD COLUMN "bidStrategy" TEXT; +ALTER TABLE "ConversionEvent" ADD COLUMN "conversionGoal" TEXT; + +-- AlterTable +ALTER TABLE "CohortSnapshot" ADD COLUMN "agency" TEXT; + +-- DropIndex (widen unique key to include agency — see schema.prisma +-- CohortSnapshot doc comment for the Postgres NULL-uniqueness caveat this +-- inherits, same as the `os` widening before it) +DROP INDEX "CohortSnapshot_orgId_appId_cohortDate_channel_os_key"; + +-- CreateIndex +CREATE UNIQUE INDEX "CohortSnapshot_orgId_appId_cohortDate_channel_os_agency_key" ON "CohortSnapshot"("orgId", "appId", "cohortDate", "channel", "os", "agency"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 02a7a50..a154c27 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -247,6 +247,9 @@ model PlatformAccount { accessToken String? refreshToken String? extra String? // JSON + // Media-buying agency of record for this account, when one exists (bi + // breakdown dimension). Null when the org buys direct. + agency String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -407,6 +410,9 @@ model Report { cpc Float @default(0) cpa Float @default(0) roas Float @default(0) + // BI breakdown dimensions (bi §6). Null for platforms/rows we can't stamp. + os String? // ios | android | web — account-level MMP rows leave this null + agency String? // mirrored from PlatformAccount.agency when known rawData String? // JSON string of full API response createdAt DateTime @default(now()) @@ -807,8 +813,15 @@ model ConversionEvent { utmSource String? utmCampaign String? channel String? // paid_meta_web | paid_asa | kol | organic | ... + os String? // ios | android | web country String? revenue Float @default(0) // net revenue for subscription events + // Campaign-name-derived dimensions (docs/growth/06-mmp-ingest.md §7): parsed + // from the MMP callback's campaign_name via campaign-name.ts. Null when the + // campaign name is missing or doesn't parse (not guessed). + agency String? + bidStrategy String? + conversionGoal String? raw String? // JSON createdAt DateTime @default(now()) @@ -828,20 +841,45 @@ model CohortSnapshot { appId String? cohortDate DateTime // acquisition day channel String + // Anchored user's OS (ios | android | web), null when unknown/pre-dates the + // bi rollout. Included in the group key (bi §6) — see the null-uniqueness + // caveat below. + os String? + // Campaign-name-derived agency for the anchored user's attributed install + // event (docs/growth/06-mmp-ingest.md §7), same source/priority as + // ConversionEvent.agency. Null when unknown. Included in the group key. + agency String? computedAt DateTime @default(now()) installs Int @default(0) + // Users anchored on a signup event (bi §6 acquisition-anchor rule) rather + // than an install — mutually exclusive with `installs` per user (a user + // with both a signup and an install anchors on the signup and is only + // counted in `signups`). installs + signups = row's anchored user count. + signups Int @default(0) activated Int @default(0) // first_chat completed d1Retained Int @default(0) d7Retained Int @default(0) trials Int @default(0) subscribers Int @default(0) revenueToDate Float @default(0) + // Revenue windows off the cohort anchor day (bi §6): D0 = anchor day only, + // D7 = anchor day .. +7d inclusive. Subsets of revenueToDate. + revenueD0 Float @default(0) + revenueD7 Float @default(0) ltvEstimate Float @default(0) cac Float? // paid channels: spend / installs org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) - @@unique([orgId, appId, cohortDate, channel]) + // NOTE on null uniqueness: Postgres treats each NULL in a unique index as + // distinct, so multiple rows with the same (orgId, appId, cohortDate, + // channel, os=NULL) would NOT violate this constraint if written via + // upsert-by-key. We rely on the growth-sync cron's delete-then-createMany + // pattern (never an upsert keyed on this constraint) to stay collision-free + // regardless — see src/app/api/cron/growth-sync/route.ts. Same caveat + // already applied to `appId` before this migration; `os` and `agency` + // follow it. + @@unique([orgId, appId, cohortDate, channel, os, agency]) @@index([orgId, cohortDate]) } diff --git a/src/app/(dashboard)/dashboard/_client.tsx b/src/app/(dashboard)/dashboard/_client.tsx index 4fc286d..e1cb6b1 100644 --- a/src/app/(dashboard)/dashboard/_client.tsx +++ b/src/app/(dashboard)/dashboard/_client.tsx @@ -1,13 +1,35 @@ 'use client' -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { StatCard } from '@/components/layout/stat-card' import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { TrendChart, type TrendSeries } from '@/components/ui/trend-chart' +import { DateRangePicker, type DateRangeValue } from '@/components/ui/date-range-picker' +import { FilterBar, SingleSelect, MultiSelect } from '@/components/ui/filter-bar' +import { FieldConfigBar } from '@/components/ui/field-config-bar' +import { DataTable, type DataTableColumn } from '@/components/ui/data-table' import { useT } from '@/components/i18n-provider' import { formatCurrency, formatNumber, api } from '@/lib/utils' +import { + quickRangeDates, + formatDateRangeLabel, + formatMoneyOrDash, + formatCountOrDash, + formatPercentOrDash, + formatRoiOrDash, + buildQueryString, + collectDistinct, + filterBreakdownRows, + aggregateBreakdownRows, + summarizeSummaryRows, + type SummaryRow, + type BreakdownRow, + type AggregatedBreakdownRow, +} from '@/lib/dashboard-bi' + +// ───────────────────────────── legacy widgets (kept, data source unchanged) ───────────────────────────── interface Report { id: string @@ -35,31 +57,228 @@ interface SyncResult { dateRange: { startDate: string; endDate: string } } +// ───────────────────────────── BI section ───────────────────────────── + +const OS_OPTIONS = (t: (k: string) => string) => [ + { value: 'ios', label: t('bi.os.ios') }, + { value: 'android', label: t('bi.os.android') }, + { value: 'web', label: t('bi.os.web') }, +] + +const SOURCE_OPTIONS = (t: (k: string) => string) => [ + { value: 'paid', label: t('bi.source.paid') }, + { value: 'organic', label: t('bi.source.organic') }, +] + export default function DashboardClient() { const { t } = useT() + + // ── shared filters ── + const [range, setRange] = useState(() => ({ ...quickRangeDates('30d'), quickRange: '30d' })) + const [os, setOs] = useState(null) + const [source, setSource] = useState(null) + const [platformFilter, setPlatformFilter] = useState([]) + const [agencyFilter, setAgencyFilter] = useState([]) + + // ── summary table (bi §6 os×source) ── + const [summaryRows, setSummaryRows] = useState([]) + const [summaryHasData, setSummaryHasData] = useState(false) + const [summaryLoading, setSummaryLoading] = useState(true) + const [summaryError, setSummaryError] = useState(false) + const [summaryOptionalKeys, setSummaryOptionalKeys] = useState([]) + + // ── breakdown table (bi §6 media delivery detail) ── + const [breakdownRows, setBreakdownRows] = useState([]) + const [breakdownHasData, setBreakdownHasData] = useState(false) + const [breakdownLoading, setBreakdownLoading] = useState(true) + const [breakdownError, setBreakdownError] = useState(false) + const [breakdownMode, setBreakdownMode] = useState<'daily' | 'aggregate'>('daily') + // Response-level join signal (bi §7) — NOT per-row. 'full' means every + // report-side group joined the funnel; the "pending" header hint only + // shows when it's not 'full'. + const [breakdownFunnelJoin, setBreakdownFunnelJoin] = useState<'full' | 'partial' | 'none'>('none') + + useEffect(() => { + setSummaryLoading(true) + setSummaryError(false) + const qs = buildQueryString({ start: range.start, end: range.end, os, source }) + fetch(api(`/api/growth/summary${qs}`)) + .then((r) => (r.ok ? r.json() : Promise.reject())) + .then((d) => { + setSummaryRows(Array.isArray(d.rows) ? d.rows : []) + setSummaryHasData(!!d.hasData) + }) + .catch(() => setSummaryError(true)) + .finally(() => setSummaryLoading(false)) + }, [range.start, range.end, os, source]) + + useEffect(() => { + setBreakdownLoading(true) + setBreakdownError(false) + // platform/agency are NOT sent to the API — the full range's rows are + // fetched once so the filter dropdowns can dynamically collect every + // platform/agency value actually present, then filtering happens + // client-side (see filterBreakdownRows below). + const qs = buildQueryString({ start: range.start, end: range.end, os }) + fetch(api(`/api/reports/breakdown${qs}`)) + .then((r) => (r.ok ? r.json() : Promise.reject())) + .then((d) => { + setBreakdownRows(Array.isArray(d.rows) ? d.rows : []) + setBreakdownHasData(!!d.hasData) + setBreakdownFunnelJoin(d.funnelJoin === 'full' || d.funnelJoin === 'partial' ? d.funnelJoin : 'none') + }) + .catch(() => setBreakdownError(true)) + .finally(() => setBreakdownLoading(false)) + }, [range.start, range.end, os]) + + const platformOptions = useMemo( + () => collectDistinct(breakdownRows, 'platform').map((v) => ({ value: v, label: v })), + [breakdownRows] + ) + const agencyOptions = useMemo( + () => collectDistinct(breakdownRows, 'agency').map((v) => ({ value: v, label: v })), + [breakdownRows] + ) + + const filteredBreakdownRows = useMemo( + () => filterBreakdownRows(breakdownRows, platformFilter, agencyFilter), + [breakdownRows, platformFilter, agencyFilter] + ) + + const rangeLabel = formatDateRangeLabel(range.start, range.end) + const aggregatedBreakdownRows = useMemo( + () => aggregateBreakdownRows(filteredBreakdownRows, rangeLabel), + [filteredBreakdownRows, rangeLabel] + ) + + const summaryTotals = useMemo(() => summarizeSummaryRows(summaryRows), [summaryRows]) + + // ── summary table columns ── + const summaryBaseColumns: DataTableColumn[] = [ + { key: 'os', label: t('bi.col.os'), format: (r) => r.os }, + { key: 'source', label: t('bi.col.source'), format: (r) => r.source }, + { key: 'spend', label: t('bi.col.spend'), align: 'right', format: (r) => formatMoneyOrDash(r.spend) }, + { key: 'signups', label: t('bi.col.signups'), align: 'right', format: (r) => formatCountOrDash(r.signups) }, + { key: 'costPerSignup', label: t('bi.col.cost_per_signup'), align: 'right', format: (r) => formatMoneyOrDash(r.costPerSignup) }, + { key: 'd1Rate', label: t('bi.col.d1_rate'), align: 'right', format: (r) => formatPercentOrDash(r.d1Rate) }, + { key: 'd7Rate', label: t('bi.col.d7_rate'), align: 'right', format: (r) => formatPercentOrDash(r.d7Rate) }, + { key: 'd0Roi', label: t('bi.col.d0_roi'), align: 'right', format: (r) => formatRoiOrDash(r.d0Roi) }, + { key: 'd7Roi', label: t('bi.col.d7_roi'), align: 'right', format: (r) => formatRoiOrDash(r.d7Roi) }, + { key: 'revenueToDate', label: t('bi.col.revenue_to_date'), title: t('bi.col.revenue_to_date_title'), align: 'right', format: (r) => formatMoneyOrDash(r.revenueToDate) }, + ] + + const summaryOptionalColumnDefs: Record> = { + subscriptionRate: { key: 'subscriptionRate', label: t('bi.col.subscription_rate'), align: 'right', format: (r) => formatPercentOrDash(r.subscriptionRate) }, + arpu7d: { key: 'arpu7d', label: t('bi.col.arpu7d'), align: 'right', format: (r) => formatMoneyOrDash(r.arpu7d) }, + arppu7d: { key: 'arppu7d', label: t('bi.col.arppu7d'), align: 'right', format: (r) => formatMoneyOrDash(r.arppu7d) }, + trialToPaidRateApprox: { key: 'trialToPaidRateApprox', label: t('bi.col.trial_to_paid_rate'), align: 'right', format: (r) => formatPercentOrDash(r.trialToPaidRateApprox) }, + trials: { key: 'trials', label: t('bi.col.trials'), align: 'right', format: (r) => formatCountOrDash(r.trials) }, + subscribers: { key: 'subscribers', label: t('bi.col.subscribers'), align: 'right', format: (r) => formatCountOrDash(r.subscribers) }, + costPerPayingUser: { key: 'costPerPayingUser', label: t('bi.col.cost_per_paying_user'), align: 'right', format: (r) => formatMoneyOrDash(r.costPerPayingUser) }, + revenueD0: { key: 'revenueD0', label: t('bi.col.revenue_d0'), align: 'right', format: (r) => formatMoneyOrDash(r.revenueD0) }, + revenueD7: { key: 'revenueD7', label: t('bi.col.revenue_d7'), align: 'right', format: (r) => formatMoneyOrDash(r.revenueD7) }, + } + const summaryOptionalFieldOptions = Object.values(summaryOptionalColumnDefs).map((c) => ({ key: c.key, label: c.label })) + + const summaryColumns: DataTableColumn[] = [ + ...summaryBaseColumns, + ...summaryOptionalKeys.map((k) => summaryOptionalColumnDefs[k]).filter(Boolean), + ] + + // totals row: only counts + derived cost-per-* are safe to aggregate across + // os×source buckets (see summarizeSummaryRows doc comment) — rate/ROI + // columns render "—" rather than a fabricated average. + const summaryTotalsRow: SummaryRow = { + os: '', + source: '', + spend: summaryTotals.spend, + signups: summaryTotals.signups, + costPerSignup: summaryTotals.costPerSignup, + d1Rate: NaN, + d7Rate: NaN, + d0Roi: null, + d7Roi: null, + subscriptionRate: NaN, + arpu7d: NaN, + arppu7d: NaN, + trialToPaidRateApprox: NaN, + trials: summaryTotals.trials, + subscribers: summaryTotals.subscribers, + costPerPayingUser: summaryTotals.costPerPayingUser, + revenueD0: summaryTotals.revenueD0, + revenueD7: summaryTotals.revenueD7, + revenueToDate: summaryTotals.revenueToDate, + } + + // ── breakdown table columns ── + // The pending hint only makes sense while the funnel bridge hasn't joined + // everything (bi §7) — a fully-joined response has real numbers in every + // row, so the "pending" title would be misleading. + const pendingTitle = breakdownFunnelJoin !== 'full' ? t('bi.col.pending_title') : undefined + const breakdownColumns: DataTableColumn[] = [ + { + key: 'date', + label: breakdownMode === 'aggregate' ? t('bi.col.date_range') : t('bi.col.date'), + format: (r) => (breakdownMode === 'aggregate' ? r.dateLabel : r.date ?? r.dateLabel), + }, + { key: 'os', label: t('bi.col.os'), format: (r) => r.os ?? '—' }, + { key: 'platform', label: t('bi.col.platform'), format: (r) => r.platform }, + { key: 'agency', label: t('bi.col.agency'), format: (r) => r.agency ?? '—' }, + { key: 'impressions', label: t('bi.col.impressions'), align: 'right', format: (r) => formatCountOrDash(r.impressions) }, + { key: 'clicks', label: t('bi.col.clicks'), align: 'right', format: (r) => formatCountOrDash(r.clicks) }, + { key: 'spend', label: t('bi.col.spend'), align: 'right', format: (r) => formatMoneyOrDash(r.spend) }, + { key: 'cpc', label: t('bi.col.cpc'), align: 'right', format: (r) => formatMoneyOrDash(r.cpc) }, + { key: 'signups', label: t('bi.col.signups'), align: 'right', title: pendingTitle, format: (r) => formatCountOrDash(r.signups) }, + { key: 'costPerSignup', label: t('bi.col.cost_per_signup'), align: 'right', title: pendingTitle, format: (r) => formatMoneyOrDash(r.costPerSignup) }, + { key: 'd1Rate', label: t('bi.col.d1_rate'), align: 'right', title: pendingTitle, format: (r) => formatPercentOrDash(r.d1Rate) }, + { key: 'd7Rate', label: t('bi.col.d7_rate'), align: 'right', title: pendingTitle, format: (r) => formatPercentOrDash(r.d7Rate) }, + { key: 'd0Roi', label: t('bi.col.d0_roi'), align: 'right', title: pendingTitle, format: (r) => formatRoiOrDash(r.d0Roi) }, + { key: 'd7Roi', label: t('bi.col.d7_roi'), align: 'right', title: pendingTitle, format: (r) => formatRoiOrDash(r.d7Roi) }, + ] + + const breakdownDisplayRows: (AggregatedBreakdownRow & { date?: string })[] = + breakdownMode === 'aggregate' + ? aggregatedBreakdownRows + : filteredBreakdownRows.map((r) => ({ + date: r.date, + dateLabel: r.date, + os: r.os, + platform: r.platform, + agency: r.agency, + impressions: r.impressions, + clicks: r.clicks, + spend: r.spend, + cpc: r.cpc, + signups: r.signups, + costPerSignup: r.costPerSignup, + d1Rate: r.d1Rate, + d7Rate: r.d7Rate, + d0Roi: r.d0Roi, + d7Roi: r.d7Roi, + })) + + // ── legacy widgets state (unchanged data source: /api/reports, /api/campaigns) ── const [reports, setReports] = useState([]) const [campaigns, setCampaigns] = useState([]) const [syncing, setSyncing] = useState(false) const [syncStatus, setSyncStatus] = useState(null) - const [loading, setLoading] = useState(true) + const [legacyLoading, setLegacyLoading] = useState(true) useEffect(() => { - loadDashboard() + loadLegacy() }, []) - async function loadDashboard() { - setLoading(true) + async function loadLegacy() { + setLegacyLoading(true) try { const [reportsRes, campaignsRes] = await Promise.all([ fetch(api('/api/reports')).catch(() => null), fetch(api('/api/campaigns')).catch(() => null), ]) - if (reportsRes?.ok) { const data = await reportsRes.json() setReports(Array.isArray(data) ? data : []) } - if (campaignsRes?.ok) { const data = await campaignsRes.json() setCampaigns(Array.isArray(data) ? data : []) @@ -67,22 +286,21 @@ export default function DashboardClient() { } catch { // Silently fail - show empty state } finally { - setLoading(false) + setLegacyLoading(false) } } const PLATFORM_COLORS: Record = { - google: '#3b82f6', - meta: '#8b5cf6', - tiktok: '#111827', - amazon: '#ea580c', - linkedin: '#0a66c2', + google: '#3b82f6', + meta: '#8b5cf6', + tiktok: '#111827', + amazon: '#ea580c', + linkedin: '#0a66c2', appsflyer: '#f59e0b', - adjust: '#10b981', + adjust: '#10b981', } function buildTrendSeries(metric: 'spend' | 'clicks' | 'conversions'): TrendSeries[] { - // Group reports by platform, then by YYYY-MM-DD const byPlatform: Record> = {} for (const r of reports) { const day = r.date.split('T')[0] @@ -117,10 +335,7 @@ export default function DashboardClient() { setSyncStatus(`Sync failed: ${err.error || 'Unknown error'}`) return } - const data: SyncResult = await res.json() - - // Show sync results const platformResults = Object.entries(data.results || {}) .map(([platform, result]) => { if (result.error) return `${platform}: ${result.error}` @@ -128,10 +343,7 @@ export default function DashboardClient() { return `${platform}: synced` }) .join(' | ') - setSyncStatus(platformResults || 'Sync complete') - - // Reload reports from DB const reportsRes = await fetch(api('/api/reports')) if (reportsRes.ok) { const reportsData = await reportsRes.json() @@ -158,10 +370,104 @@ export default function DashboardClient() {

{t('page.dashboard.title')}

-

{t('page.dashboard.subtitle')}

+

{t('bi.dashboard.subtitle')}

-
-
+ + {/* ── shared date range + filters ── */} +
+ + + + + + + +
+ + {/* ── table 1: OS × source summary ── */} + + + {t('bi.table.os_summary.title')} + ({ key: c.key, label: c.label }))} + optionalFields={summaryOptionalFieldOptions} + onChange={setSummaryOptionalKeys} + /> + + + {summaryLoading ? ( +

Loading…

+ ) : summaryError ? ( +
+ Couldn't load summary data. Try again shortly. +
+ ) : ( + `${r.os}|${r.source}`} + totals={summaryHasData ? summaryTotalsRow : undefined} + totalsLabel={t('bi.table.totals')} + emptyTitle={t('bi.empty.summary')} + /> + )} +
+
+ + {/* ── table 2: media delivery detail ── */} + + + {t('bi.table.breakdown.title')} + + + + {breakdownLoading ? ( +

Loading…

+ ) : breakdownError ? ( +
+ Couldn't load delivery data. Try again shortly. +
+ ) : ( + `${r.date ?? r.dateLabel}|${r.os}|${r.platform}|${r.agency}|${i}`} + emptyTitle={t('bi.empty.breakdown')} + /> + )} + {!breakdownHasData && !breakdownLoading && !breakdownError && ( +

{pendingTitle}

+ )} +
+
+ + {/* ── legacy overview (kept below, unchanged data source) ── */} +
+

{t('bi.legacy.title')}

+ + {syncStatus && ( +
+
+ {syncStatus} + +
+
+ )} + + -
- {/* Sync status banner */} - {syncStatus && ( -
-
- {syncStatus} - -
-
- )} - - {loading ? ( -
- {[1, 2, 3, 4].map(i => ( -
-
-
-
- ))} -
- ) : ( - <> + {legacyLoading ? (
- 💰} /> - 1 ? 'positive' : 'negative'} change={`ROAS: ${roas.toFixed(2)}x`} icon={📈} /> - 👁️} /> - 🎯} /> + {[1, 2, 3, 4].map((i) => ( +
+
+
+
+ ))}
+ ) : ( +
+
+ 💰} /> + 1 ? 'positive' : 'negative'} change={`ROAS: ${roas.toFixed(2)}x`} icon={📈} /> + 👁️} /> + 🎯} /> +
- {reports.length === 0 && campaigns.length === 0 && ( - - -

📊

-

No data yet

-

- Connect your ad platforms in Settings, then click "Sync Data" to pull performance metrics. -

-
-
- )} + {reports.length === 0 && campaigns.length === 0 && ( + + +

📊

+

No data yet

+

+ Connect your ad platforms in Settings, then click "Sync Data" to pull performance metrics. +

+
+
+ )} - {reports.length > 0 && ( - - - Spend by Platform (last 7 days) - - - formatCurrency(n)} - /> - - - )} + {reports.length > 0 && ( + + + Spend by Platform (last 7 days) + + + formatCurrency(n)} /> + + + )} -
- - - Active Campaigns - - - {campaigns.length === 0 ? ( -

No campaigns yet. Create your first campaign to get started.

- ) : ( -
- {campaigns.slice(0, 5).map((c) => ( -
-
-

{c.name}

-

{c.platform}

+
+ + + Active Campaigns + + + {campaigns.length === 0 ? ( +

No campaigns yet. Create your first campaign to get started.

+ ) : ( +
+ {campaigns.slice(0, 5).map((c) => ( +
+
+

{c.name}

+

{c.platform}

+
+
+ {c.budgets?.[0] && ( + + {formatCurrency(c.budgets[0].spent)} / {formatCurrency(c.budgets[0].amount)} + + )} + {c.status} +
-
- {c.budgets?.[0] && ( - - {formatCurrency(c.budgets[0].spent)} / {formatCurrency(c.budgets[0].amount)} - - )} - {c.status} + ))} +
+ )} + + + + + + Platform Breakdown + + + {[ + { id: 'google', label: 'Google Ads', icon: '🔵' }, + { id: 'meta', label: 'Meta (Facebook)', icon: '🟣' }, + { id: 'tiktok', label: 'TikTok', icon: '⬛' }, + { id: 'amazon', label: 'Amazon Ads', icon: '🟠' }, + { id: 'linkedin', label: 'LinkedIn Ads', icon: '🔷' }, + { id: 'appsflyer', label: 'AppsFlyer', icon: '📱' }, + { id: 'adjust', label: 'Adjust', icon: '📐' }, + ].map(({ id, label, icon }) => { + const platformReports = reports.filter((r) => r.platform === id) + if (platformReports.length === 0) return null + const spend = platformReports.reduce((s, r) => s + (r.spend || 0), 0) + const clicks = platformReports.reduce((s, r) => s + (r.clicks || 0), 0) + const conversions = platformReports.reduce((s, r) => s + (r.conversions || 0), 0) + return ( +
+
+ {icon} + {label} +
+
+

{formatCurrency(spend)} spent

+

{formatNumber(clicks)} clicks · {formatNumber(conversions)} conv.

- ))} -
- )} -
-
- - - - Platform Breakdown - - - {[ - { id: 'google', label: 'Google Ads', icon: '🔵' }, - { id: 'meta', label: 'Meta (Facebook)', icon: '🟣' }, - { id: 'tiktok', label: 'TikTok', icon: '⬛' }, - { id: 'amazon', label: 'Amazon Ads', icon: '🟠' }, - { id: 'linkedin', label: 'LinkedIn Ads', icon: '🔷' }, - { id: 'appsflyer', label: 'AppsFlyer', icon: '📱' }, - { id: 'adjust', label: 'Adjust', icon: '📐' }, - ].map(({ id, label, icon }) => { - const platformReports = reports.filter(r => r.platform === id) - if (platformReports.length === 0) return null - const spend = platformReports.reduce((s, r) => s + (r.spend || 0), 0) - const clicks = platformReports.reduce((s, r) => s + (r.clicks || 0), 0) - const conversions = platformReports.reduce((s, r) => s + (r.conversions || 0), 0) - return ( -
-
- {icon} - {label} -
-
-

{formatCurrency(spend)} spent

-

{formatNumber(clicks)} clicks · {formatNumber(conversions)} conv.

-
-
- ) - })} -
-
+ ) + })} + + +
- - )} + )} +
) } diff --git a/src/app/api/cron/growth-sync/route.ts b/src/app/api/cron/growth-sync/route.ts index deb7176..347a3b0 100644 --- a/src/app/api/cron/growth-sync/route.ts +++ b/src/app/api/cron/growth-sync/route.ts @@ -2,7 +2,9 @@ import { NextRequest, NextResponse } from 'next/server' import { verifyCronAuth } from '@/lib/cron-auth' import { prisma } from '@/lib/prisma' import { buildCohortSnapshots, type RawEvent } from '@/lib/growth/cohorts' -import { EVENTS, type EventName } from '@/lib/growth/events' +import { EVENTS, SOURCES, type EventName } from '@/lib/growth/events' +import { resolveInstallAuthority } from '@/lib/growth/kpi-canon' +import { CHANNELS, type Channel } from '@/lib/growth/channels' /** * POST /api/cron/growth-sync @@ -11,8 +13,11 @@ import { EVENTS, type EventName } from '@/lib/growth/events' * their ConversionEvent rows. Idempotent: deletes the window's snapshots and * re-inserts, so re-running never double-counts. Auth via X-Cron-Secret. * - * Scheduled daily (Cloud Scheduler). CAC is left null here — per-channel spend - * attribution (Report → channel) lands with the paid-execution layer (P19). + * Scheduled daily (Cloud Scheduler). CAC is computed from Report spend, + * mapped to a channel via PLATFORM_TO_CHANNEL below (bi §6, + * docs/growth/06-mmp-ingest.md §6) — platforms we can't confidently map to a + * single pilot channel are excluded and their spend reported separately as + * `unallocatedSpend` rather than guessed onto some channel. * * Ref: docs/growth/00-cuddler-first-redesign.md §10 (P18) */ @@ -20,6 +25,27 @@ import { EVENTS, type EventName } from '@/lib/growth/events' const WINDOW_DAYS = 60 const KNOWN_EVENTS = new Set(Object.values(EVENTS)) +/** + * Report.platform → growth Channel, for attributing media spend to cohorts. + * Deliberately conservative and partial: Report is an account-level daily + * total per ad platform with NO channel/os dimension of its own, so we can + * only map platforms that map 1:1 onto a single pilot channel. `google` / + * `meta` / `tiktok` default to their *_web arm (the pilot's deterministic + * spine) since Report can't disambiguate web vs iOS-SKAN spend either. + * `adjust` / `appsflyer` are MMPs, not ad channels — their Report rows are + * legacy MMP-reported totals (docs/growth/06-mmp-ingest.md §0), and mapping + * them here would double-count spend already attributed to the platform that + * bought the media. `amazon` / `linkedin` have no corresponding pilot + * channel constant yet. All of these are left unmapped; their spend is + * reported as `unallocatedSpend` in the cron summary rather than silently + * dropped or guessed onto some channel. + */ +const PLATFORM_TO_CHANNEL: Partial> = { + google: CHANNELS.PAID_GOOGLE_UAC, + meta: CHANNELS.PAID_META_WEB, + tiktok: CHANNELS.PAID_TIKTOK_WEB, +} + export async function POST(req: NextRequest) { if (!(await verifyCronAuth(req, 'growth-sync'))) { return NextResponse.json({ error: 'Unauthorized — set X-Cron-Secret header' }, { status: 401 }) @@ -28,12 +54,19 @@ export async function POST(req: NextRequest) { const cutoff = new Date(Date.now() - WINDOW_DAYS * 86_400_000) const orgs = await prisma.organization.findMany({ select: { id: true } }) - const summary: Array<{ orgId: string; cohorts: number; events: number }> = [] + const summary: Array<{ + orgId: string + cohorts: number + events: number + installAuthority?: string + installAuthorityWarning?: string + unallocatedSpend?: number + }> = [] for (const org of orgs) { const rows = await prisma.conversionEvent.findMany({ where: { orgId: org.id, occurredAt: { gte: cutoff } }, - select: { eventName: true, occurredAt: true, userKey: true, channel: true, revenue: true }, + select: { eventName: true, occurredAt: true, userKey: true, channel: true, os: true, agency: true, revenue: true, source: true }, }) const events: RawEvent[] = rows @@ -43,10 +76,55 @@ export async function POST(req: NextRequest) { occurredAt: r.occurredAt, userKey: r.userKey, channel: r.channel, + os: r.os, + agency: r.agency, revenue: r.revenue, + source: r.source, })) - const cohorts = buildCohortSnapshots(events) + // Media spend → cohort attribution (bi §6): sum Report.spend in the same + // window, keyed by `${date}|${channel}` via PLATFORM_TO_CHANNEL. Spend on + // platforms we can't map to a channel is tallied separately rather than + // silently dropped or guessed. + const reportRows = await prisma.report.findMany({ + where: { orgId: org.id, date: { gte: cutoff } }, + select: { date: true, platform: true, spend: true }, + }) + const spendByCohort = new Map() + let unallocatedSpend = 0 + for (const r of reportRows) { + const channel = PLATFORM_TO_CHANNEL[r.platform] + if (!channel) { + unallocatedSpend += r.spend + continue + } + const key = `${r.date.toISOString().slice(0, 10)}|${channel}` + spendByCohort.set(key, (spendByCohort.get(key) ?? 0) + r.spend) + } + + // Install authority (decision A, docs/growth/06-mmp-ingest.md §2): if the + // org has Adjust wired, Adjust's install events are authoritative and the + // GA4 install rows for the same window are excluded from cohort placement + // (see cohorts.ts). The anti-zeroing guard in resolveInstallAuthority falls + // back to GA4 if Adjust is configured but reported 0 installs this window. + const hasAdjustAuth = await prisma.platformAuth.findUnique({ + where: { orgId_platform: { orgId: org.id, platform: 'adjust' } }, + }).then((a) => !!a?.isActive).catch(() => false) + const adjustInstallCount = events.filter((e) => e.eventName === EVENTS.INSTALL && e.source === SOURCES.ADJUST).length + const ga4InstallCount = events.filter((e) => e.eventName === EVENTS.INSTALL && e.source === SOURCES.GA4).length + const installAuthorityResult = resolveInstallAuthority({ + hasAdjustAuth, + adjustInstallCount, + ga4InstallCount, + }) + if (installAuthorityResult.warning) { + console.warn(`[growth-sync] org=${org.id} ${installAuthorityResult.warning}`) + } + + const cohorts = buildCohortSnapshots(events, { + installAuthority: installAuthorityResult.authority, + spendByCohort, + }) // Idempotent recompute: clear the window, then insert fresh. await prisma.cohortSnapshot.deleteMany({ @@ -59,20 +137,32 @@ export async function POST(req: NextRequest) { appId: null, cohortDate: new Date(c.cohortDate + 'T00:00:00.000Z'), channel: c.channel, + os: c.os, + agency: c.agency, installs: c.installs, + signups: c.signups, activated: c.activated, d1Retained: c.d1Retained, d7Retained: c.d7Retained, trials: c.trials, subscribers: c.subscribers, revenueToDate: c.revenueToDate, + revenueD0: c.revenueD0, + revenueD7: c.revenueD7, ltvEstimate: c.ltvEstimate, cac: c.cac, })), }) } - summary.push({ orgId: org.id, cohorts: cohorts.length, events: events.length }) + summary.push({ + orgId: org.id, + cohorts: cohorts.length, + events: events.length, + installAuthority: installAuthorityResult.authority, + ...(installAuthorityResult.warning ? { installAuthorityWarning: installAuthorityResult.warning } : {}), + ...(unallocatedSpend > 0 ? { unallocatedSpend } : {}), + }) } return NextResponse.json({ ok: true, window: `${WINDOW_DAYS}d`, orgs: summary }) diff --git a/src/app/api/growth/channels/route.ts b/src/app/api/growth/channels/route.ts index 0dc510a..7087e61 100644 --- a/src/app/api/growth/channels/route.ts +++ b/src/app/api/growth/channels/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from 'next/server' import { prisma } from '@/lib/prisma' import { requireAuthWithOrg } from '@/lib/auth' -import { activationRate, retentionRate, subscriptionRate, realizedLtv, eCACStar } from '@/lib/growth/kpi-canon' +import { activationRate, retentionRate, subscriptionRate, realizedLtv, eCACStar, isMatureForRetentionWindow } from '@/lib/growth/kpi-canon' import { isSkanChannel, type Channel } from '@/lib/growth/channels' import { evaluateChannel } from '@/lib/growth/pilot-gates' @@ -25,25 +25,30 @@ export async function GET() { const snaps = await prisma.cohortSnapshot.findMany({ where: { orgId: org.id } }) - type Agg = { installs: number; activated: number; d1: number; d7: number; trials: number; subscribers: number; revenue: number; spend: number; hasSpend: boolean } + // `installs` = total cohort size (install- + signup-anchored). d7Base is the + // maturity-gated D7 denominator (bi §6 D7-dilution fix) — see + // kpi-canon.isMatureForRetentionWindow. + type Agg = { installs: number; activated: number; d1: number; d1Base: number; d7: number; d7Base: number; trials: number; subscribers: number; revenue: number; spend: number; hasSpend: boolean } const byChannel = new Map() for (const s of snaps) { let a = byChannel.get(s.channel) - if (!a) { a = { installs: 0, activated: 0, d1: 0, d7: 0, trials: 0, subscribers: 0, revenue: 0, spend: 0, hasSpend: false }; byChannel.set(s.channel, a) } - a.installs += s.installs + if (!a) { a = { installs: 0, activated: 0, d1: 0, d1Base: 0, d7: 0, d7Base: 0, trials: 0, subscribers: 0, revenue: 0, spend: 0, hasSpend: false }; byChannel.set(s.channel, a) } + const cohortSize = s.installs + s.signups + const cohortDateKey = s.cohortDate.toISOString().slice(0, 10) + a.installs += cohortSize a.activated += s.activated - a.d1 += s.d1Retained - a.d7 += s.d7Retained + if (isMatureForRetentionWindow(cohortDateKey, 1)) { a.d1 += s.d1Retained; a.d1Base += cohortSize } + if (isMatureForRetentionWindow(cohortDateKey, 7)) { a.d7 += s.d7Retained; a.d7Base += cohortSize } a.trials += s.trials a.subscribers += s.subscribers a.revenue += s.revenueToDate - if (s.cac !== null) { a.spend += s.cac * s.installs; a.hasSpend = true } + if (s.cac !== null) { a.spend += s.cac * cohortSize; a.hasSpend = true } } const channels = [...byChannel.entries()] .map(([channel, a]) => { const actRate = activationRate(a.activated, a.installs) - const d7Rate = retentionRate(a.d7, a.installs) + const d7Rate = retentionRate(a.d7, a.d7Base) const ecac = a.hasSpend ? eCACStar({ spend: a.spend, mediaSubsidyCost: 0, installs: a.installs }) : null // Gate runs on real payment signal (subscribers) + funnel proxies. const gate = evaluateChannel({ @@ -60,7 +65,7 @@ export async function GET() { skan: isSkanChannel(channel as Channel), installs: a.installs, activationRate: actRate, - d1Rate: retentionRate(a.d1, a.installs), + d1Rate: retentionRate(a.d1, a.d1Base), d7Rate, trials: a.trials, subscribers: a.subscribers, diff --git a/src/app/api/growth/cohorts/route.ts b/src/app/api/growth/cohorts/route.ts index b609d98..a6f7940 100644 --- a/src/app/api/growth/cohorts/route.ts +++ b/src/app/api/growth/cohorts/route.ts @@ -23,17 +23,21 @@ export async function GET() { take: 200, }) - const rows = snaps.map((s) => ({ - cohortDate: s.cohortDate.toISOString().slice(0, 10), - channel: s.channel, - installs: s.installs, - activationRate: activationRate(s.activated, s.installs), - d1Rate: retentionRate(s.d1Retained, s.installs), - d7Rate: retentionRate(s.d7Retained, s.installs), - subscribers: s.subscribers, - subscriptionRate: subscriptionRate(s.subscribers, s.installs), - ltv: realizedLtv(s.revenueToDate, s.installs), - })) + const rows = snaps.map((s) => { + const cohortSize = s.installs + s.signups + return { + cohortDate: s.cohortDate.toISOString().slice(0, 10), + channel: s.channel, + os: s.os, + installs: cohortSize, + activationRate: activationRate(s.activated, cohortSize), + d1Rate: retentionRate(s.d1Retained, cohortSize), + d7Rate: retentionRate(s.d7Retained, cohortSize), + subscribers: s.subscribers, + subscriptionRate: subscriptionRate(s.subscribers, cohortSize), + ltv: realizedLtv(s.revenueToDate, cohortSize), + } + }) return NextResponse.json({ hasData: rows.length > 0, rows }) } diff --git a/src/app/api/growth/overview/route.ts b/src/app/api/growth/overview/route.ts index cbd3b33..78880ab 100644 --- a/src/app/api/growth/overview/route.ts +++ b/src/app/api/growth/overview/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from 'next/server' import { prisma } from '@/lib/prisma' import { requireAuthWithOrg } from '@/lib/auth' -import { activationRate, retentionRate, subscriptionRate, realizedLtv } from '@/lib/growth/kpi-canon' +import { activationRate, retentionRate, subscriptionRate, realizedLtv, isMatureForRetentionWindow } from '@/lib/growth/kpi-canon' /** * GET /api/growth/overview @@ -25,13 +25,36 @@ export async function GET() { orderBy: { cohortDate: 'desc' }, }) - type Agg = { installs: number; activated: number; d1: number; d7: number; trials: number; subscribers: number; revenue: number } - const zero = (): Agg => ({ installs: 0, activated: 0, d1: 0, d7: 0, trials: 0, subscribers: 0, revenue: 0 }) + // `installs` here means total cohort size (install-anchored + signup- + // anchored users) — see kpi-canon retentionRate doc comment. d1Base/d7Base + // are separate, maturity-gated denominators (bi §6 D7-dilution fix): a + // cohort whose cohortDate+N hasn't happened yet is excluded from BOTH the + // numerator and denominator of that D_N rate, not counted as "not retained". + type Agg = { + installs: number + activated: number + d1: number + d1Base: number + d7: number + d7Base: number + trials: number + subscribers: number + revenue: number + } + const zero = (): Agg => ({ installs: 0, activated: 0, d1: 0, d1Base: 0, d7: 0, d7Base: 0, trials: 0, subscribers: 0, revenue: 0 }) const add = (a: Agg, s: (typeof snaps)[number]) => { - a.installs += s.installs + const cohortSize = s.installs + s.signups + const cohortDateKey = s.cohortDate.toISOString().slice(0, 10) + a.installs += cohortSize a.activated += s.activated - a.d1 += s.d1Retained - a.d7 += s.d7Retained + if (isMatureForRetentionWindow(cohortDateKey, 1)) { + a.d1 += s.d1Retained + a.d1Base += cohortSize + } + if (isMatureForRetentionWindow(cohortDateKey, 7)) { + a.d7 += s.d7Retained + a.d7Base += cohortSize + } a.trials += s.trials a.subscribers += s.subscribers a.revenue += s.revenueToDate @@ -50,7 +73,7 @@ export async function GET() { } add(c, s) if (s.cac !== null) { - c.spend += s.cac * s.installs + c.spend += s.cac * (s.installs + s.signups) c.hasSpend = true } if (!latest || s.computedAt > latest) latest = s.computedAt @@ -60,8 +83,8 @@ export async function GET() { installs: a.installs, activated: a.activated, activationRate: activationRate(a.activated, a.installs), - d1Rate: retentionRate(a.d1, a.installs), - d7Rate: retentionRate(a.d7, a.installs), + d1Rate: retentionRate(a.d1, a.d1Base), + d7Rate: retentionRate(a.d7, a.d7Base), trials: a.trials, subscribers: a.subscribers, subscriptionRate: subscriptionRate(a.subscribers, a.installs), diff --git a/src/app/api/growth/summary/route.ts b/src/app/api/growth/summary/route.ts new file mode 100644 index 0000000..e4b7160 --- /dev/null +++ b/src/app/api/growth/summary/route.ts @@ -0,0 +1,187 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { requireAuthWithOrg } from '@/lib/auth' +import { + retentionRate, + subscriptionRate, + costPerSignup, + costPerPayingUser, + roi, + arpu, + arppu, + trialToPaidRate, + isMatureForRetentionWindow, +} from '@/lib/growth/kpi-canon' +import { isChannel, isPaidChannel, type Channel } from '@/lib/growth/channels' +import { isOs } from '@/lib/growth/events' + +/** + * GET /api/growth/summary?start&end&os&source(paid|organic)&channel&agency + * + * bi §6 (docs/growth/06-mmp-ingest.md §6) — per-os × paid/organic funnel + * summary rolled up from CohortSnapshot. `source` buckets a row's channel via + * `isPaidChannel`; a channel this app doesn't recognize (shouldn't happen, + * CohortSnapshot.channel is always written from our own taxonomy, but the + * column has no DB-level enum) is conservatively bucketed as organic rather + * than thrown away. + * + * `agency` (bi §7) is query-level filtering only — the summary bucket is + * still os×source, NOT further split by agency (that's the breakdown + * endpoint's job). + * + * `trialToPaidRateApprox` is an approximation, not a cohort-matched trial + * conversion rate: it's subscribers/trials within the SAME filtered window, + * so a trial started near the window's end and its eventual conversion + * (which may land after the window closes) are not necessarily the same + * batch of users. See kpi-canon.trialToPaidRate's own doc comment. + */ +export async function GET(req: NextRequest) { + let org + try { + ;({ org } = await requireAuthWithOrg()) + } catch { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const url = new URL(req.url) + const startParam = url.searchParams.get('start') + const endParam = url.searchParams.get('end') + const osFilter = url.searchParams.get('os') + const sourceFilter = url.searchParams.get('source') + const channelFilter = url.searchParams.get('channel') + const agencyFilter = url.searchParams.get('agency') + + if (osFilter && !isOs(osFilter)) { + return NextResponse.json({ error: 'invalid os — must be ios|android|web' }, { status: 400 }) + } + if (sourceFilter && sourceFilter !== 'paid' && sourceFilter !== 'organic') { + return NextResponse.json({ error: 'invalid source — must be paid|organic' }, { status: 400 }) + } + if (channelFilter && !isChannel(channelFilter)) { + return NextResponse.json({ error: 'invalid channel' }, { status: 400 }) + } + const start = startParam ? new Date(startParam) : null + const end = endParam ? new Date(endParam) : null + if ((startParam && Number.isNaN(start!.getTime())) || (endParam && Number.isNaN(end!.getTime()))) { + return NextResponse.json({ error: 'invalid start/end date' }, { status: 400 }) + } + + const cohortDate: { gte?: Date; lte?: Date } = {} + if (start) cohortDate.gte = start + if (end) cohortDate.lte = end + + const snaps = await prisma.cohortSnapshot.findMany({ + where: { + orgId: org.id, + ...(start || end ? { cohortDate } : {}), + ...(osFilter ? { os: osFilter } : {}), + ...(channelFilter ? { channel: channelFilter } : {}), + ...(agencyFilter ? { agency: agencyFilter } : {}), + }, + }) + + type Agg = { + spend: number + hasSpend: boolean + signups: number + cohortSize: number // installs + signups, this bucket's "acquired users" + activated: number + d1: number + d1Base: number + d7: number + d7Base: number + trials: number + subscribers: number + revenueD0: number + revenueD7: number + revenueToDate: number + } + const zero = (): Agg => ({ + spend: 0, + hasSpend: false, + signups: 0, + cohortSize: 0, + activated: 0, + d1: 0, + d1Base: 0, + d7: 0, + d7Base: 0, + trials: 0, + subscribers: 0, + revenueD0: 0, + revenueD7: 0, + revenueToDate: 0, + }) + + const buckets = new Map() + for (const s of snaps) { + const paid = isChannel(s.channel) ? isPaidChannel(s.channel as Channel) : false + const sourceBucket: 'paid' | 'organic' = paid ? 'paid' : 'organic' + if (sourceFilter && sourceFilter !== sourceBucket) continue + + const osBucket = s.os ?? 'unknown' + const key = `${osBucket}|${sourceBucket}` + let a = buckets.get(key) + if (!a) { + a = zero() + buckets.set(key, a) + } + + const cohortSize = s.installs + s.signups + const cohortDateKey = s.cohortDate.toISOString().slice(0, 10) + + a.cohortSize += cohortSize + a.signups += s.signups + a.activated += s.activated + a.trials += s.trials + a.subscribers += s.subscribers + a.revenueD0 += s.revenueD0 + a.revenueD7 += s.revenueD7 + a.revenueToDate += s.revenueToDate + if (s.cac !== null) { + a.spend += s.cac * cohortSize + a.hasSpend = true + } + if (isMatureForRetentionWindow(cohortDateKey, 1)) { + a.d1 += s.d1Retained + a.d1Base += cohortSize + } + if (isMatureForRetentionWindow(cohortDateKey, 7)) { + a.d7 += s.d7Retained + a.d7Base += cohortSize + } + } + + const rows = [...buckets.entries()] + .map(([key, a]) => { + const [os, source] = key.split('|') + return { + os, + source, + spend: a.hasSpend ? a.spend : null, + signups: a.signups, + costPerSignup: a.hasSpend ? costPerSignup(a.spend, a.signups) : null, + d1Rate: retentionRate(a.d1, a.d1Base), + d7Rate: retentionRate(a.d7, a.d7Base), + d0Roi: a.hasSpend ? roi(a.revenueD0, a.spend) : null, + d7Roi: a.hasSpend ? roi(a.revenueD7, a.spend) : null, + subscriptionRate: subscriptionRate(a.subscribers, a.cohortSize), + arpu7d: arpu(a.revenueD7, a.cohortSize), + arppu7d: arppu(a.revenueD7, a.subscribers), + trialToPaidRateApprox: trialToPaidRate(a.subscribers, a.trials), + trials: a.trials, + subscribers: a.subscribers, + costPerPayingUser: a.hasSpend ? costPerPayingUser(a.spend, a.subscribers) : null, + revenueD0: a.revenueD0, + revenueD7: a.revenueD7, + // Lifetime-to-computedAt revenue of cohorts anchored in the range. For + // ranges ending today this equals "range revenue"; for historical + // ranges it includes revenue earned after the range end — an arbitrary + // event-window aggregation over ConversionEvent is future work. + revenueToDate: a.revenueToDate, + } + }) + .sort((x, y) => (x.os === y.os ? x.source.localeCompare(y.source) : x.os.localeCompare(y.os))) + + return NextResponse.json({ hasData: rows.length > 0, rows }) +} diff --git a/src/app/api/ingest/adjust/route.ts b/src/app/api/ingest/adjust/route.ts new file mode 100644 index 0000000..6d0a116 --- /dev/null +++ b/src/app/api/ingest/adjust/route.ts @@ -0,0 +1,129 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { verifyBearer, readBearer } from '@/lib/growth/ingest-auth' +import { mapAdjustCallback, type AdjustCallbackParams, type AdjustEventTokenMap } from '@/lib/growth/adjust-ingest' +import { EVENTS, type EventName } from '@/lib/growth/events' + +const EVENT_NAME_SET = new Set(Object.values(EVENTS)) + +/** + * GET|POST /api/ingest/adjust?org=&token= + * + * Receives an Adjust real-time S2S callback (docs/growth/06-mmp-ingest.md §3) + * and writes a normalized ConversionEvent. Adjust's callback is a URL template + * it expands itself — it can't sign a per-request HMAC and typically fires as + * a GET, so auth is a static secret compared constant-time, same as + * /api/ingest/revenuecat, except the secret may arrive via `?token=` (Adjust + * can't set custom headers) in addition to `Authorization: Bearer`. + * + * Auth: PlatformAuth(orgId, platform='ingest_adjust').apiKey, falling back to + * the INGEST_ADJUST_SECRET env var — a slot distinct from the events-route + * HMAC secret, because this key travels in cleartext query strings. The same + * row's `extra` JSON supplies the event-token map (custom Adjust event tokens + * → canonical event names). + * + * A single callback maps to at most one event; unmapped activity_kind / + * event_token combinations are dropped (200 ok, not written) so Adjust never + * sees a 4xx/5xx and retries. Idempotent via the ConversionEvent unique key. + */ +export async function GET(req: NextRequest) { + return handle(req) +} + +export async function POST(req: NextRequest) { + return handle(req) +} + +async function handle(req: NextRequest) { + const url = new URL(req.url) + const orgId = url.searchParams.get('org') + if (!orgId) { + return NextResponse.json({ error: 'missing ?org' }, { status: 400 }) + } + + // Distinct secret slot (platform='ingest_adjust', env INGEST_ADJUST_SECRET). + // Deliberately NOT the '/api/ingest/events' HMAC secret: this route has to + // transmit its key in cleartext via `?token=` (Adjust can't set headers), + // which lands in LB/access logs — a leak here must not let anyone forge + // HMAC signatures for the events route. The same row's `extra` JSON also + // carries the org's Adjust event-token map: {"eventTokenMap":{"abc123":"trial_start"}}. + let expected: string | undefined = process.env.INGEST_ADJUST_SECRET + let eventTokenMap: AdjustEventTokenMap | undefined + try { + const auth = await prisma.platformAuth.findUnique({ + where: { orgId_platform: { orgId, platform: 'ingest_adjust' } }, + }) + if (auth?.apiKey) expected = auth.apiKey + if (auth?.extra) { + try { + const extra = JSON.parse(auth.extra) as { eventTokenMap?: Record } + if (extra.eventTokenMap && typeof extra.eventTokenMap === 'object') { + // Only keep entries whose value is a canonical EventName — a typo'd + // config entry degrades to "token unmapped → dropped", never a bad row. + const validated: AdjustEventTokenMap = {} + for (const [token, name] of Object.entries(extra.eventTokenMap)) { + if (typeof name === 'string' && EVENT_NAME_SET.has(name)) { + validated[token] = name as EventName + } + } + eventTokenMap = validated + } + } catch { + // malformed extra JSON — proceed without a map (installs still ingest) + } + } + } catch { + // fall through to env fallback + } + + const provided = readBearer(req.headers.get('authorization')) ?? url.searchParams.get('token') + if (!verifyBearer(provided, expected)) { + return NextResponse.json({ error: 'unauthorized' }, { status: 401 }) + } + + const params: AdjustCallbackParams = Object.fromEntries(url.searchParams.entries()) + if (req.method === 'POST') { + try { + const form = await req.formData() + for (const [k, v] of form.entries()) { + if (typeof v === 'string') params[k] = v + } + } catch { + // no form body — GET-style query params only, that's fine + } + } + // Never persist the auth secret into ConversionEvent.raw — strip AFTER the + // form merge, or a form field named `token` would smuggle it back in. + delete params.token + + const mapped = mapAdjustCallback(params, eventTokenMap) + if (!mapped) { + // Not funnel-relevant (reattribution/session, unmapped event token). Ack + // so Adjust doesn't retry. + return NextResponse.json({ ok: true, ignored: true }) + } + + const result = await prisma.conversionEvent.createMany({ + data: [ + { + orgId, + source: mapped.source, + eventName: mapped.eventName, + occurredAt: mapped.occurredAt, + userKey: mapped.userKey ?? null, + utmCampaign: mapped.utmCampaign ?? null, + channel: mapped.channel ?? null, + os: mapped.os ?? null, + country: mapped.country ?? null, + revenue: mapped.revenue ?? 0, + agency: mapped.agency ?? null, + bidStrategy: mapped.bidStrategy ?? null, + conversionGoal: mapped.conversionGoal ?? null, + raw: JSON.stringify(mapped.raw ?? params), + }, + ], + skipDuplicates: true, + }) + + return NextResponse.json({ ok: true, written: result.count }) +} diff --git a/src/app/api/ingest/events/route.ts b/src/app/api/ingest/events/route.ts index aa7403b..1c7475a 100644 --- a/src/app/api/ingest/events/route.ts +++ b/src/app/api/ingest/events/route.ts @@ -58,8 +58,10 @@ export async function POST(req: NextRequest) { utmSource: e.utmSource ?? null, utmCampaign: e.utmCampaign ?? null, channel: e.channel ?? null, + os: e.os ?? null, country: e.country ?? null, revenue: e.revenue ?? 0, + agency: e.agency ?? null, raw: JSON.stringify(e.raw ?? e), })), skipDuplicates: true, diff --git a/src/app/api/ingest/revenuecat/route.ts b/src/app/api/ingest/revenuecat/route.ts index f834f52..b5cd363 100644 --- a/src/app/api/ingest/revenuecat/route.ts +++ b/src/app/api/ingest/revenuecat/route.ts @@ -62,6 +62,7 @@ export async function POST(req: NextRequest) { occurredAt: mapped.occurredAt, userKey: mapped.userKey ?? null, channel: mapped.channel ?? null, + os: mapped.os ?? null, country: mapped.country ?? null, revenue: mapped.revenue ?? 0, raw: JSON.stringify(mapped.raw ?? payload), diff --git a/src/app/api/reports/breakdown/route.ts b/src/app/api/reports/breakdown/route.ts new file mode 100644 index 0000000..a5c587b --- /dev/null +++ b/src/app/api/reports/breakdown/route.ts @@ -0,0 +1,177 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { requireAuthWithOrg } from '@/lib/auth' +import { cpc, aggregateCohortWindow, computeFunnelMetrics, type CohortWindowRow } from '@/lib/growth/kpi-canon' +import { isOs } from '@/lib/growth/events' +import { channelToPlatform } from '@/lib/growth/channels' + +/** + * GET /api/reports/breakdown?start&end&os&platform&agency + * + * bi §6/§7 — media-delivery detail rows from Report, dimensioned + * date > os > platform > agency, joined back to the funnel + * (CohortSnapshot) on that same (date, os, platform, agency) key. + * + * The join key's `platform` side comes from `channelToPlatform(channel)` + * (docs/growth/06-mmp-ingest.md §7) — earned/organic channels have no ad + * platform and never join. A Report row joins when at least one + * CohortSnapshot row shares its exact (date, os, platform, agency) key; + * unmatched rows keep every funnel column `null` rather than a fabricated 0 + * (a row that legitimately joined to zero signups looks identical to one + * that never joined otherwise — see the `funnelJoin` response field below + * for the aggregate signal). + * + * `funnelJoin` is a RESPONSE-level field (not per-row): 'full' when every + * report-side group joined, 'partial' when some did, 'none' when none did + * (including the trivial case of zero report rows). Callers use it to decide + * whether to show a "funnel pending" banner — a per-row pending marker isn't + * needed once real numbers are available for the rows that did join. + */ +export async function GET(req: NextRequest) { + let org + try { + ;({ org } = await requireAuthWithOrg()) + } catch { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const url = new URL(req.url) + const startParam = url.searchParams.get('start') + const endParam = url.searchParams.get('end') + const osFilter = url.searchParams.get('os') + const platformFilter = url.searchParams.get('platform') + const agencyFilter = url.searchParams.get('agency') + + if (osFilter && !isOs(osFilter)) { + return NextResponse.json({ error: 'invalid os — must be ios|android|web' }, { status: 400 }) + } + const start = startParam ? new Date(startParam) : null + const end = endParam ? new Date(endParam) : null + if ((startParam && Number.isNaN(start!.getTime())) || (endParam && Number.isNaN(end!.getTime()))) { + return NextResponse.json({ error: 'invalid start/end date' }, { status: 400 }) + } + + // Server-side window guard: a request omitting start would otherwise scan + // every Report row the org has. Default to a rolling window ending at `end` + // (or now), mirroring growth-sync's WINDOW_DAYS — the UI always sends an + // explicit range, so this only bounds hand-crafted requests. + const WINDOW_DAYS = 60 + const effectiveEnd = end ?? new Date() + const effectiveStart = start ?? new Date(effectiveEnd.getTime() - WINDOW_DAYS * 86_400_000) + + const date: { gte?: Date; lte?: Date } = { gte: effectiveStart } + if (end) date.lte = end + + const reports = await prisma.report.findMany({ + where: { + orgId: org.id, + date, + ...(osFilter ? { os: osFilter } : {}), + ...(platformFilter ? { platform: platformFilter } : {}), + ...(agencyFilter ? { agency: agencyFilter } : {}), + }, + select: { date: true, os: true, platform: true, agency: true, impressions: true, clicks: true, spend: true }, + }) + + type Agg = { impressions: number; clicks: number; spend: number } + const groups = new Map() + for (const r of reports) { + const dateKey = r.date.toISOString().slice(0, 10) + const key = `${dateKey}|${r.os ?? ''}|${r.platform}|${r.agency ?? ''}` + let g = groups.get(key) + if (!g) { + g = { date: dateKey, os: r.os, platform: r.platform, agency: r.agency, impressions: 0, clicks: 0, spend: 0 } + groups.set(key, g) + } + g.impressions += r.impressions + g.clicks += r.clicks + g.spend += r.spend + } + + // Funnel bridge (bi §7): CohortSnapshot rows in the same window, keyed the + // same way via channelToPlatform. Same start/end/os/agency filters as the + // Report query above — a coherent join needs both sides looking at the + // same slice. `platformFilter` has no CohortSnapshot analogue (Report's + // `platform` is the ad-buying platform; CohortSnapshot has no platform + // column, only `channel`), so it's intentionally not applied here — the + // Report side already restricts to that platform, and the join key match + // takes care of the rest. + // Same effective window as the Report query — a coherent join needs both + // sides bounded identically (incl. the default-window guard). + const cohortDate: { gte?: Date; lte?: Date } = { gte: effectiveStart } + if (end) cohortDate.lte = end + const cohorts = await prisma.cohortSnapshot.findMany({ + where: { + orgId: org.id, + cohortDate, + ...(osFilter ? { os: osFilter } : {}), + ...(agencyFilter ? { agency: agencyFilter } : {}), + }, + select: { cohortDate: true, channel: true, os: true, agency: true, installs: true, signups: true, d1Retained: true, d7Retained: true, revenueD0: true, revenueD7: true }, + }) + + const cohortsByKey = new Map() + for (const c of cohorts) { + const platform = channelToPlatform(c.channel) + if (!platform) continue // earned/organic channels have no ad platform — never join + const dateKey = c.cohortDate.toISOString().slice(0, 10) + const key = `${dateKey}|${c.os ?? ''}|${platform}|${c.agency ?? ''}` + const row: CohortWindowRow = { + cohortDate: dateKey, + installs: c.installs, + signups: c.signups, + d1Retained: c.d1Retained, + d7Retained: c.d7Retained, + revenueD0: c.revenueD0, + revenueD7: c.revenueD7, + } + const arr = cohortsByKey.get(key) + if (arr) arr.push(row) + else cohortsByKey.set(key, [row]) + } + + let joinedGroups = 0 + const rows = [...groups.values()] + .map((g) => { + const key = `${g.date}|${g.os ?? ''}|${g.platform}|${g.agency ?? ''}` + const cohortRows = cohortsByKey.get(key) + const joined = !!cohortRows && cohortRows.length > 0 + if (joined) joinedGroups += 1 + + const funnel = joined + ? (() => { + const agg = aggregateCohortWindow(cohortRows!) + const metrics = computeFunnelMetrics({ + spend: g.spend, + signups: agg.signups, + d1Retained: agg.d1, + d1Base: agg.d1Base, + d7Retained: agg.d7, + d7Base: agg.d7Base, + revenueD0: agg.revenueD0, + revenueD7: agg.revenueD7, + }) + return { signups: agg.signups, ...metrics } + })() + : { signups: null, costPerSignup: null, d1Rate: null, d7Rate: null, d0Roi: null, d7Roi: null } + + return { + date: g.date, + os: g.os, + platform: g.platform, + agency: g.agency, + impressions: g.impressions, + clicks: g.clicks, + spend: g.spend, + cpc: cpc(g.spend, g.clicks), + ...funnel, + } + }) + .sort((x, y) => (x.date === y.date ? x.platform.localeCompare(y.platform) : x.date.localeCompare(y.date))) + + const totalGroups = groups.size + const funnelJoin: 'full' | 'partial' | 'none' = + totalGroups === 0 || joinedGroups === 0 ? 'none' : joinedGroups === totalGroups ? 'full' : 'partial' + + return NextResponse.json({ hasData: rows.length > 0, funnelJoin, rows }) +} diff --git a/src/app/api/reports/sync/route.ts b/src/app/api/reports/sync/route.ts index 9848914..6c6c1a7 100644 --- a/src/app/api/reports/sync/route.ts +++ b/src/app/api/reports/sync/route.ts @@ -7,7 +7,7 @@ import { AmazonAdsClient } from '@/lib/platforms/amazon' import { LinkedInAdsClient } from '@/lib/platforms/linkedin' import type { PlatformAuth } from '@/generated/prisma/client' import { getAdapter, isAdaptablePlatform } from '@/lib/platforms/registry' -import { runAdapterSync } from '@/lib/sync/report-writer' +import { runAdapterSync, resolveReportAgency } from '@/lib/sync/report-writer' type SyncMetrics = { impressions: number @@ -46,11 +46,14 @@ async function upsertReport( platform: string, date: Date, metrics: SyncMetrics, - raw: unknown + raw: unknown, + extra: { os?: string | null; agency?: string | null } = {} ) { const endDateStr = date.toISOString().split('T')[0] const reportId = `${platform}-${orgId}-${endDateStr}` const d = derived(metrics) + const os = extra.os ?? null + const agency = extra.agency ?? null await prisma.report.upsert({ where: { id: reportId }, @@ -62,6 +65,8 @@ async function upsertReport( revenue: metrics.revenue, installs: metrics.installs, ...d, + os, + agency, rawData: JSON.stringify(raw), }, create: { @@ -77,6 +82,8 @@ async function upsertReport( revenue: metrics.revenue, installs: metrics.installs, ...d, + os, + agency, rawData: JSON.stringify(raw), }, }) @@ -136,11 +143,15 @@ async function syncAppsFlyer( // Conversions on install side = installs metrics.conversions = metrics.installs - await upsertReport(orgId, userId, 'appsflyer', today, metrics, { - rows: data.length, - startDate, - endDate, - }) + await upsertReport( + orgId, + userId, + 'appsflyer', + today, + metrics, + { rows: data.length, startDate, endDate }, + { agency: await resolveReportAgency(orgId, 'appsflyer') } + ) return { success: true, rows: data.length, ...metrics } } @@ -182,11 +193,15 @@ async function syncAdjust( } metrics.conversions = metrics.installs - await upsertReport(orgId, userId, 'adjust', today, metrics, { - rows: rows.length, - startDate, - endDate, - }) + await upsertReport( + orgId, + userId, + 'adjust', + today, + metrics, + { rows: rows.length, startDate, endDate }, + { agency: await resolveReportAgency(orgId, 'adjust') } + ) return { success: true, rows: rows.length, ...metrics } } @@ -223,7 +238,7 @@ async function syncAmazon( const agg = await client.getAggregatedReport(startDate, endDate) const metrics = { ...emptyMetrics(), ...agg } - await upsertReport(orgId, userId, 'amazon', today, metrics, { startDate, endDate }) + await upsertReport(orgId, userId, 'amazon', today, metrics, { startDate, endDate }, { agency: await resolveReportAgency(orgId, 'amazon') }) return { success: true, ...metrics } } @@ -248,7 +263,7 @@ async function syncLinkedIn( const agg = await client.getAggregatedReport(startDate, endDate) const metrics = { ...emptyMetrics(), ...agg } - await upsertReport(orgId, userId, 'linkedin', today, metrics, { startDate, endDate }) + await upsertReport(orgId, userId, 'linkedin', today, metrics, { startDate, endDate }, { agency: await resolveReportAgency(orgId, 'linkedin') }) return { success: true, ...metrics } } diff --git a/src/components/ui/data-table.tsx b/src/components/ui/data-table.tsx new file mode 100644 index 0000000..ca2f4f4 --- /dev/null +++ b/src/components/ui/data-table.tsx @@ -0,0 +1,95 @@ +import { cn } from '@/lib/utils' +import { EmptyState } from './empty-state' + +export interface DataTableColumn { + key: string + label: string + align?: 'left' | 'right' + /** Cell renderer; receives the row. Defaults to String(row[key]). */ + format?: (row: T) => React.ReactNode + /** Tooltip on the header cell (e.g. "待归因打通" for funnel-join-pending columns). */ + title?: string +} + +interface DataTableProps { + columns: DataTableColumn[] + rows: T[] + getRowKey: (row: T, index: number) => string + /** Rendered as a bolded final row, e.g. a grand-total. Same column shape as `rows`. */ + totals?: T + totalsLabel?: string + emptyEmoji?: string + emptyTitle?: string + emptyDescription?: string + className?: string +} + +/** + * Generic, business-agnostic table: column defs + row data + optional bolded + * totals row. Follows the growth pages' hand-rolled table styling + * (overflow-x-auto wrapper, text-sm, border-b rows) so it drops into any BI + * page without a new visual language. + */ +export function DataTable({ + columns, + rows, + getRowKey, + totals, + totalsLabel = '合计', + emptyEmoji = '📊', + emptyTitle = '暂无数据', + emptyDescription, + className, +}: DataTableProps) { + if (rows.length === 0) { + return + } + + return ( +
+ + + + {columns.map((c) => ( + + ))} + + + + {rows.map((row, i) => ( + + {columns.map((c) => ( + + ))} + + ))} + + {totals && ( + + + {columns.map((c, i) => ( + + ))} + + + )} +
+ {c.label} +
+ {c.format ? c.format(row) : String((row as Record)[c.key] ?? '—')} +
+ {i === 0 ? totalsLabel : c.format ? c.format(totals) : String((totals as Record)[c.key] ?? '—')} +
+
+ ) +} diff --git a/src/components/ui/date-range-picker.tsx b/src/components/ui/date-range-picker.tsx new file mode 100644 index 0000000..2960116 --- /dev/null +++ b/src/components/ui/date-range-picker.tsx @@ -0,0 +1,84 @@ +'use client' + +import { cn } from '@/lib/utils' +import { quickRangeDates, type QuickRange } from '@/lib/dashboard-bi' + +const PRESETS: { value: Exclude; label: string }[] = [ + { value: '7d', label: '近7天' }, + { value: '14d', label: '近14天' }, + { value: '30d', label: '近30天' }, +] + +export interface DateRangeValue { + start: string + end: string + quickRange: QuickRange +} + +interface DateRangePickerProps { + value: DateRangeValue + onChange: (next: DateRangeValue) => void + className?: string +} + +/** + * Generic start/end date-range control with quick-range presets, native + * `` for custom ranges. No date-picker dependency. + */ +export function DateRangePicker({ value, onChange, className }: DateRangePickerProps) { + function selectPreset(preset: Exclude) { + const { start, end } = quickRangeDates(preset) + onChange({ start, end, quickRange: preset }) + } + + return ( +
+
+ {PRESETS.map((p) => ( + + ))} + +
+ onChange({ ...value, start: e.target.value, quickRange: 'custom' })} + className="rounded-lg border border-gray-300 px-2 py-1 text-sm" + aria-label="start date" + /> + + onChange({ ...value, end: e.target.value, quickRange: 'custom' })} + className="rounded-lg border border-gray-300 px-2 py-1 text-sm" + aria-label="end date" + /> +
+ ) +} diff --git a/src/components/ui/field-config-bar.tsx b/src/components/ui/field-config-bar.tsx new file mode 100644 index 0000000..f4b2b1c --- /dev/null +++ b/src/components/ui/field-config-bar.tsx @@ -0,0 +1,105 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import { cn } from '@/lib/utils' +import { mergeFieldConfig, toggleFieldVisibility, reorderFields, visibleKeys, type FieldToggle } from '@/lib/dashboard-bi' + +export interface FieldOption { + key: string + label: string +} + +interface FieldConfigBarProps { + /** localStorage key this bar's column order/visibility persists under — include the page name, e.g. "adex.dashboard.summary.columns". */ + storageKey: string + /** Always-shown columns, rendered as static (non-interactive) chips for context. */ + baseFields: FieldOption[] + /** Toggleable columns. Checked ones are added to the table, in the order shown here. */ + optionalFields: FieldOption[] + /** Called with the ordered list of currently-visible optional keys whenever the config changes (including the initial load from storage). */ + onChange: (visibleOptionalKeysInOrder: string[]) => void + className?: string +} + +/** + * Base + optional field picker. Optional fields are checkbox-toggled and + * reorderable via native HTML5 drag-and-drop (no dnd library) — dragging + * only ever reorders within the optional set, base fields are fixed. + * Persists to localStorage under `storageKey`. + */ +export function FieldConfigBar({ storageKey, baseFields, optionalFields, onChange, className }: FieldConfigBarProps) { + const allKeys = optionalFields.map((f) => f.key) + const labelOf = (key: string) => optionalFields.find((f) => f.key === key)?.label ?? key + + const [fields, setFields] = useState(() => mergeFieldConfig(allKeys, null)) + const dragIndex = useRef(null) + // Stable string key for the effect dep array below — `allKeys` is a fresh + // array every render, `.join` gives it referential-equality-friendly identity. + const optionalKeysJoined = allKeys.join(',') + + // Load persisted config on mount (and whenever the underlying column set changes). + useEffect(() => { + let stored: FieldToggle[] | null = null + try { + const raw = window.localStorage.getItem(storageKey) + if (raw) stored = JSON.parse(raw) + } catch { + // ignore malformed/blocked storage + } + setFields(mergeFieldConfig(allKeys, stored)) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [storageKey, optionalKeysJoined]) + + useEffect(() => { + onChange(visibleKeys(fields)) + try { + window.localStorage.setItem(storageKey, JSON.stringify(fields)) + } catch { + // ignore (private mode / quota) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fields, storageKey]) + + function handleDrop(toIndex: number) { + if (dragIndex.current === null) return + setFields((prev) => reorderFields(prev, dragIndex.current!, toIndex)) + dragIndex.current = null + } + + return ( +
+
+ 基础字段 + {baseFields.map((f) => ( + + {f.label} + + ))} +
+
+ 可选字段(拖拽排序) + {fields.map((f, i) => ( + + ))} +
+
+ ) +} diff --git a/src/components/ui/filter-bar.tsx b/src/components/ui/filter-bar.tsx new file mode 100644 index 0000000..e51e911 --- /dev/null +++ b/src/components/ui/filter-bar.tsx @@ -0,0 +1,111 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import { cn } from '@/lib/utils' + +export interface FilterOption { + value: string + label: string +} + +interface MultiSelectProps { + label: string + options: FilterOption[] + /** Empty array means "全部" (no filter applied). */ + selected: string[] + onChange: (next: string[]) => void + className?: string +} + +/** + * Checkbox-list dropdown styled like ui/select.tsx's border/focus treatment. + * Selection state: [] == "全部" (all). Trigger label shows "全部" or "N selected". + */ +export function MultiSelect({ label, options, selected, onChange, className }: MultiSelectProps) { + const [open, setOpen] = useState(false) + const ref = useRef(null) + + useEffect(() => { + function onDocClick(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) + } + document.addEventListener('mousedown', onDocClick) + return () => document.removeEventListener('mousedown', onDocClick) + }, []) + + function toggle(value: string) { + onChange(selected.includes(value) ? selected.filter((v) => v !== value) : [...selected, value]) + } + + const summary = selected.length === 0 ? '全部' : `已选 ${selected.length}` + + return ( +
+ + {open && ( +
+ {options.length === 0 ? ( +

无可选项

+ ) : ( + options.map((o) => ( + + )) + )} + {selected.length > 0 && ( + + )} +
+ )} +
+ ) +} + +interface SingleSelectProps { + label: string + options: FilterOption[] + /** null/'' means "全部" (no filter applied). */ + value: string | null + onChange: (next: string | null) => void + className?: string +} + +/** Native onChange(e.target.value || null)} + className="rounded-lg border border-gray-300 px-2 py-1.5 text-sm text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" + > + + {options.map((o) => ( + + ))} + + + ) +} + +/** Thin flex-wrap row — just layout, the individual selects carry their own logic. */ +export function FilterBar({ children, className }: { children: React.ReactNode; className?: string }) { + return
{children}
+} diff --git a/src/lib/agent/act.test.ts b/src/lib/agent/act.test.ts new file mode 100644 index 0000000..17dd6d0 --- /dev/null +++ b/src/lib/agent/act.test.ts @@ -0,0 +1,223 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// vi.mock is hoisted — define stubs inside the factory so they're +// initialised before the module under test imports them. +vi.mock('@/lib/prisma', () => ({ + prisma: { + decision: { findFirst: vi.fn(), update: vi.fn(), create: vi.fn() }, + decisionStep: { update: vi.fn(), create: vi.fn(), createMany: vi.fn() }, + pendingApproval: { create: vi.fn() }, + $transaction: vi.fn(), + }, +})) + +vi.mock('./tools', () => ({ + getTool: vi.fn(), +})) + +vi.mock('./guardrails', () => ({ + evaluateGuardrails: vi.fn(), + isBlocked: vi.fn((results: Array<{ pass: boolean }>) => results.some((r) => !r.pass)), +})) + +vi.mock('@/lib/webhooks', () => ({ + fireWebhook: vi.fn(() => Promise.resolve()), +})) + +vi.mock('./notify', () => ({ + notifyApprovers: vi.fn(() => Promise.resolve()), +})) + +vi.mock('@/lib/audit', () => ({ + logAudit: vi.fn(() => Promise.resolve()), +})) + +import { executeApprovedDecision } from './act' +import { prisma } from '@/lib/prisma' +import { getTool } from './tools' +import { evaluateGuardrails } from './guardrails' +import { logAudit } from '@/lib/audit' + +const mockedPrisma = prisma as unknown as { + decision: { findFirst: ReturnType; update: ReturnType } + decisionStep: { update: ReturnType } +} +const mockedGetTool = getTool as unknown as ReturnType +const mockedEvaluateGuardrails = evaluateGuardrails as unknown as ReturnType +const mockedLogAudit = logAudit as unknown as ReturnType + +function makeTool(execute = vi.fn(async () => ({ ok: true, output: {} }))) { + return { + name: 'adjust_daily_budget', + description: '', + inputSchema: {}, + reversible: true, + riskLevel: 'medium' as const, + validate: (i: unknown) => i, + execute, + } +} + +function makeStep(overrides: Record = {}) { + return { + id: 'step1', + decisionId: 'd1', + stepIndex: 0, + toolName: 'adjust_daily_budget', + toolInput: JSON.stringify({ campaignId: 'c1', newDailyBudget: 100 }), + toolOutput: null, + status: 'pending', + guardrailReport: null, + platformResponse: null, + platformLinkId: null, + reversible: true, + rollbackOf: null, + executedAt: null, + createdAt: new Date(), + ...overrides, + } +} + +beforeEach(() => { + vi.clearAllMocks() + mockedPrisma.decision.update.mockResolvedValue({}) + mockedPrisma.decisionStep.update.mockResolvedValue({}) +}) + +describe('executeApprovedDecision — fresh guardrail re-check', () => { + it('blocks a step when the fresh re-check finds a violation (non-rollback)', async () => { + const execute = vi.fn(async () => ({ ok: true, output: {} })) + const tool = makeTool(execute) + mockedGetTool.mockReturnValue(tool) + const step = makeStep() + mockedPrisma.decision.findFirst.mockResolvedValue({ + id: 'd1', + orgId: 'o1', + steps: [step], + }) + mockedEvaluateGuardrails.mockResolvedValue([ + { pass: false, rule: 'pilot_budget_cap', reason: 'over cap' }, + ]) + + const result = await executeApprovedDecision('o1', 'd1', 'autonomous') + + expect(execute).not.toHaveBeenCalled() + expect(mockedPrisma.decisionStep.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'step1' }, + data: expect.objectContaining({ status: 'blocked' }), + }) + ) + expect(mockedLogAudit).toHaveBeenCalled() + expect(result.status).toBe('failed') + }) + + it('proceeds normally when the fresh re-check passes', async () => { + const execute = vi.fn(async () => ({ ok: true, output: {} })) + const tool = makeTool(execute) + mockedGetTool.mockReturnValue(tool) + const step = makeStep() + mockedPrisma.decision.findFirst.mockResolvedValue({ + id: 'd1', + orgId: 'o1', + steps: [step], + }) + mockedEvaluateGuardrails.mockResolvedValue([{ pass: true, rule: 'pilot_budget_cap' }]) + + const result = await executeApprovedDecision('o1', 'd1', 'autonomous') + + expect(execute).toHaveBeenCalled() + expect(result.status).toBe('executed') + }) + + it('persists the fresh guardrail report on the success path (warn signals survive)', async () => { + const execute = vi.fn(async () => ({ ok: true, output: {} })) + const tool = makeTool(execute) + mockedGetTool.mockReturnValue(tool) + const step = makeStep() + mockedPrisma.decision.findFirst.mockResolvedValue({ + id: 'd1', + orgId: 'o1', + steps: [step], + }) + const fresh = [{ pass: true, rule: 'pilot_budget_cap', reason: 'pilot spend 82% of cap: warn' }] + mockedEvaluateGuardrails.mockResolvedValue(fresh) + + await executeApprovedDecision('o1', 'd1', 'autonomous') + + expect(mockedPrisma.decisionStep.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + status: 'executed', + guardrailReport: JSON.stringify(fresh), + }), + }) + ) + }) +}) + +describe('executeApprovedDecision — rollback exemption', () => { + // Rollback detection signal: DecisionStep.rollbackOf is set on every + // inverse step created by both decisions/[id]/rollback and + // decisions/bulk-rollback routes. + it('executes despite pilot_budget_cap / tier_cac_ceiling violations when rolling back', async () => { + const execute = vi.fn(async () => ({ ok: true, output: {} })) + const tool = makeTool(execute) + mockedGetTool.mockReturnValue(tool) + const step = makeStep({ rollbackOf: 'original-step-1' }) + mockedPrisma.decision.findFirst.mockResolvedValue({ + id: 'd1', + orgId: 'o1', + steps: [step], + }) + mockedEvaluateGuardrails.mockResolvedValue([ + { pass: false, rule: 'pilot_budget_cap', reason: 'over cap' }, + { pass: false, rule: 'tier_cac_ceiling', reason: 'cac too high' }, + ]) + + const result = await executeApprovedDecision('o1', 'd1', 'autonomous') + + expect(execute).toHaveBeenCalled() + expect(result.status).toBe('executed') + }) + + it('executes despite a skan_maturity violation alone when rolling back (warn-only)', async () => { + const execute = vi.fn(async () => ({ ok: true, output: {} })) + const tool = makeTool(execute) + mockedGetTool.mockReturnValue(tool) + const step = makeStep({ rollbackOf: 'original-step-1' }) + mockedPrisma.decision.findFirst.mockResolvedValue({ + id: 'd1', + orgId: 'o1', + steps: [step], + }) + mockedEvaluateGuardrails.mockResolvedValue([ + { pass: false, rule: 'skan_maturity', reason: 'campaign too young' }, + ]) + + const result = await executeApprovedDecision('o1', 'd1', 'autonomous') + + expect(execute).toHaveBeenCalled() + expect(result.status).toBe('executed') + }) + + it('still blocks a rollback step on an unrelated violated rule', async () => { + const execute = vi.fn(async () => ({ ok: true, output: {} })) + const tool = makeTool(execute) + mockedGetTool.mockReturnValue(tool) + const step = makeStep({ rollbackOf: 'original-step-1' }) + mockedPrisma.decision.findFirst.mockResolvedValue({ + id: 'd1', + orgId: 'o1', + steps: [step], + }) + mockedEvaluateGuardrails.mockResolvedValue([ + { pass: false, rule: 'high_risk_requires_approval', reason: 'high risk' }, + ]) + + const result = await executeApprovedDecision('o1', 'd1', 'autonomous') + + expect(execute).not.toHaveBeenCalled() + expect(result.status).toBe('failed') + }) +}) diff --git a/src/lib/agent/act.ts b/src/lib/agent/act.ts index f47f865..e214635 100644 --- a/src/lib/agent/act.ts +++ b/src/lib/agent/act.ts @@ -1,8 +1,9 @@ import { prisma } from '@/lib/prisma' import { fireWebhook } from '@/lib/webhooks' import { getTool } from './tools' -import { evaluateGuardrails, isBlocked } from './guardrails' +import { evaluateGuardrails, isBlocked, type GuardrailEvalResult } from './guardrails' import { notifyApprovers } from './notify' +import { logAudit } from '@/lib/audit' import type { AgentMode, PlanResult, @@ -150,6 +151,14 @@ export async function executeApprovedDecision( }) if (!decision) throw new Error(`Decision ${decisionId} not found`) + // Rollback detection: both rollback routes (decisions/[id]/rollback and + // decisions/bulk-rollback) stamp every inverse DecisionStep's `rollbackOf` + // with the source step id it reverses (see prisma schema DecisionStep. + // rollbackOf) and record `{ rollbackOf: original.id }` in the rollback + // Decision's perceiveContext. `rollbackOf` on the steps we already loaded + // is the simplest reliable per-Decision signal — no extra query needed. + const isRollback = decision.steps.some((s) => s.rollbackOf != null) + await prisma.decision.update({ where: { id: decisionId }, data: { status: 'executing', executedAt: new Date() }, @@ -200,6 +209,55 @@ export async function executeApprovedDecision( continue } + // Re-check guardrails immediately before execution — the shared choke + // point for every path that reaches executeApprovedDecision (approvals, + // approvals/bulk, rollback, bulk-rollback, slack/interactive). State can + // have changed since processOne's original evaluation (e.g. approval sat + // pending for hours), so we can't trust the stored guardrailReport alone. + const freshResults = await evaluateGuardrails({ + orgId, + step: { tool: step.toolName, input: JSON.parse(step.toolInput) }, + tool, + }) + + // Rollback exemption: rolling back restores a previous (already + // guardrail-passed-once) state, so blocking a rollback on the pilot + // budget cap or CAC ceiling would strand the system in a bad state with + // no way back — those two are excluded from the blocking check entirely + // during rollback. skan_maturity's concern about touching an + // immature-data campaign still deserves a visible warning even during + // rollback, so its result is kept in the report but never allowed to + // block. + const blockingResults: GuardrailEvalResult[] = isRollback + ? freshResults.filter((r) => r.rule !== 'pilot_budget_cap' && r.rule !== 'tier_cac_ceiling' && r.rule !== 'skan_maturity') + : freshResults + + if (isBlocked(blockingResults)) { + const failed = freshResults.filter((r) => !r.pass) + await prisma.decisionStep.update({ + where: { id: step.id }, + data: { + status: 'blocked', + toolOutput: JSON.stringify({ blocked: true, guardrails: freshResults }), + guardrailReport: JSON.stringify(freshResults), + }, + }) + anyFailed = true + await logAudit({ + orgId, + action: 'advisor.apply', + targetType: 'decision_step', + targetId: step.id, + metadata: { + result: 'blocked_at_execution', + decisionId, + rule: failed.map((r) => r.rule), + reasons: failed.map((r) => r.reason), + }, + }) + continue + } + const ctx: ToolContext = { orgId, decisionId, stepIndex: step.stepIndex, mode } let result: ToolResult try { @@ -215,6 +273,10 @@ export async function executeApprovedDecision( platformResponse: result.ok && result.platformResponse ? JSON.stringify(result.platformResponse) : null, platformLinkId: result.ok && result.platformLinkId ? result.platformLinkId : null, executedAt: new Date(), + // Persist the execution-time re-check on the success path too — warn- + // level signals (learning phase, 80%-of-cap) would otherwise be lost, + // leaving only the stale proposal-time report on the row. + guardrailReport: JSON.stringify(freshResults), }, }) if (!result.ok) anyFailed = true diff --git a/src/lib/agent/guardrail-schemas.ts b/src/lib/agent/guardrail-schemas.ts index 462c5c4..b303a34 100644 --- a/src/lib/agent/guardrail-schemas.ts +++ b/src/lib/agent/guardrail-schemas.ts @@ -247,6 +247,56 @@ export const GUARDRAIL_SCHEMAS: GuardrailSchema[] = [ }, ], }, + { + rule: 'pilot_budget_cap', + label: { en: 'Growth pilot spend cap', zh: '增长试点花费上限' }, + description: { + en: 'Inert until pilotStartDate is set. Once set, blocks spend-increasing steps once cumulative org spend since that date reaches the auto-pause threshold (95% of capTotal).', + zh: '默认不生效,需先设置 pilotStartDate。设置后,一旦该日期以来的组织累计花费达到自动暂停阈值(capTotal 的 95%),即拦截所有会增加花费的操作。', + }, + fields: [ + { + name: 'pilotStartDate', + label: { en: 'Pilot start date', zh: '试点开始日期' }, + hint: { en: 'ISO date, e.g. 2026-01-01. Leave unset to keep this rule inert.', zh: 'ISO 日期,如 2026-01-01。不填则此规则不生效。' }, + type: { kind: 'string' }, + default: '', + }, + { + name: 'capTotal', + label: { en: 'Pilot cap total (USD)', zh: '试点总预算上限(USD)' }, + hint: { en: 'default 5000', zh: '默认 5000' }, + type: { kind: 'number', min: 1 }, + default: 5000, + }, + ], + }, + { + rule: 'skan_maturity', + label: { en: 'Require SKAN data maturity', zh: 'SKAN 数据成熟度要求' }, + description: { + en: 'For Meta/TikTok app_install campaigns (SKAN-attributed iOS), reject automated adjustments within 72h of launch, and reject learning-phase (≤7d) spend runaways beyond 2× daily cap.', + zh: '针对 Meta/TikTok app_install 广告系列(SKAN 归因 iOS),启动 72 小时内拒绝任何自动化调整;学习期(≤7 天)内花费超过日预算 2 倍时也拒绝。', + }, + fields: [], + }, + { + rule: 'tier_cac_ceiling', + label: { en: 'Cap bid/budget increases by tier CAC ceiling', zh: '按分层 CAC 上限限制加价/加预算' }, + description: { + en: 'Reject bid/budget increases on a channel whose most recent CohortSnapshot CAC exceeds firstMonthNet × 5. No CohortSnapshot data → not blocked.', + zh: '当渠道最近一次 CohortSnapshot 的 CAC 超过 firstMonthNet × 5 时,拒绝该渠道的加价/加预算操作。无 CohortSnapshot 数据时不拦截。', + }, + fields: [ + { + name: 'firstMonthNet', + label: { en: 'First-month net revenue (USD)', zh: '首月净收入(USD)' }, + hint: { en: 'default 8.5', zh: '默认 8.5' }, + type: { kind: 'number', min: 0 }, + default: 8.5, + }, + ], + }, ] export function getSchema(rule: string): GuardrailSchema | undefined { diff --git a/src/lib/agent/guardrails.test.ts b/src/lib/agent/guardrails.test.ts index 78a6d56..e93a667 100644 --- a/src/lib/agent/guardrails.test.ts +++ b/src/lib/agent/guardrails.test.ts @@ -9,8 +9,10 @@ vi.mock('@/lib/prisma', () => ({ decisionStep: { findFirst: vi.fn(), count: vi.fn() }, platformLink: { findFirst: vi.fn(), findMany: vi.fn() }, campaignSnapshot: { findFirst: vi.fn() }, - report: { findMany: vi.fn() }, + report: { findMany: vi.fn(), aggregate: vi.fn() }, guardrail: { findMany: vi.fn() }, + cohortSnapshot: { findFirst: vi.fn() }, + budget: { findFirst: vi.fn() }, }, })) @@ -25,8 +27,10 @@ const mockedPrisma = prisma as unknown as { decisionStep: { findFirst: ReturnType; count: ReturnType } platformLink: { findFirst: ReturnType; findMany: ReturnType } campaignSnapshot: { findFirst: ReturnType } - report: { findMany: ReturnType } + report: { findMany: ReturnType; aggregate: ReturnType } guardrail: { findMany: ReturnType } + cohortSnapshot: { findFirst: ReturnType } + budget: { findFirst: ReturnType } } const tool = (name: string, riskLevel: 'low' | 'medium' | 'high' = 'low'): ToolDefinition => ({ @@ -51,7 +55,10 @@ beforeEach(() => { mockedPrisma.platformLink.findMany.mockResolvedValue([]) mockedPrisma.campaignSnapshot.findFirst.mockResolvedValue(null) mockedPrisma.report.findMany.mockResolvedValue([]) + mockedPrisma.report.aggregate.mockResolvedValue({ _sum: { spend: 0 } }) mockedPrisma.guardrail.findMany.mockResolvedValue([]) + mockedPrisma.cohortSnapshot.findFirst.mockResolvedValue(null) + mockedPrisma.budget.findFirst.mockResolvedValue(null) }) describe('evaluateGuardrails — built-in defaults', () => { @@ -185,3 +192,264 @@ describe('isBlocked', () => { expect(isBlocked([])).toBe(false) }) }) + +describe('pilot_budget_cap', () => { + it('not applicable to a non-spend-increasing tool', async () => { + const results = await evaluateGuardrails({ + orgId: 'o1', + step: { tool: 'pause_campaign', input: { campaignId: 'c1' } }, + tool: tool('pause_campaign', 'low'), + }) + expect(results.find((r) => r.rule === 'pilot_budget_cap')?.pass).toBe(true) + }) + + it('inert when no pilotStartDate configured (no DB call)', async () => { + const results = await evaluateGuardrails({ + orgId: 'o1', + step: { tool: 'resume_campaign', input: { campaignId: 'c1' } }, + tool: tool('resume_campaign', 'low'), + }) + expect(results.find((r) => r.rule === 'pilot_budget_cap')?.pass).toBe(true) + expect(mockedPrisma.report.findMany).not.toHaveBeenCalled() + }) + + it('pass true, no reason, when spend is below warn threshold', async () => { + mockedPrisma.guardrail.findMany.mockResolvedValueOnce([ + { rule: 'pilot_budget_cap', config: JSON.stringify({ pilotStartDate: '2026-01-01', capTotal: 5000 }) }, + ]) + mockedPrisma.report.findMany.mockResolvedValueOnce([{ spend: 1000 }]) + const results = await evaluateGuardrails({ + orgId: 'o1', + step: { tool: 'resume_campaign', input: { campaignId: 'c1' } }, + tool: tool('resume_campaign', 'low'), + }) + const r = results.find((r) => r.rule === 'pilot_budget_cap') + expect(r?.pass).toBe(true) + expect(r?.reason).toBeUndefined() + }) + + it('pass true with a warn reason at 80% of cap', async () => { + mockedPrisma.guardrail.findMany.mockResolvedValueOnce([ + { rule: 'pilot_budget_cap', config: JSON.stringify({ pilotStartDate: '2026-01-01', capTotal: 5000 }) }, + ]) + mockedPrisma.report.findMany.mockResolvedValueOnce([{ spend: 4000 }]) + const results = await evaluateGuardrails({ + orgId: 'o1', + step: { tool: 'resume_campaign', input: { campaignId: 'c1' } }, + tool: tool('resume_campaign', 'low'), + }) + // Two evaluations run: the always-on builtin (config {}, inert — pass) + // and the org-configured one (config with pilotStartDate). Check the + // org-configured occurrence specifically (the one carrying a reason). + const orgResult = results.filter((r) => r.rule === 'pilot_budget_cap').find((r) => r.reason) + expect(orgResult?.pass).toBe(true) + expect(orgResult?.reason).toBeDefined() + }) + + it('blocks at/above 95% of cap', async () => { + mockedPrisma.guardrail.findMany.mockResolvedValueOnce([ + { rule: 'pilot_budget_cap', config: JSON.stringify({ pilotStartDate: '2026-01-01', capTotal: 5000 }) }, + ]) + mockedPrisma.report.findMany.mockResolvedValueOnce([{ spend: 4750 }]) + const results = await evaluateGuardrails({ + orgId: 'o1', + step: { tool: 'resume_campaign', input: { campaignId: 'c1' } }, + tool: tool('resume_campaign', 'low'), + }) + expect(results.some((r) => r.rule === 'pilot_budget_cap' && !r.pass)).toBe(true) + }) + + it('sums account-level rows only — campaign rows duplicate the same spend', async () => { + mockedPrisma.guardrail.findMany.mockResolvedValueOnce([ + { rule: 'pilot_budget_cap', config: JSON.stringify({ pilotStartDate: '2026-01-01', capTotal: 5000 }) }, + ]) + mockedPrisma.report.findMany.mockResolvedValueOnce([{ spend: 1000 }]) + await evaluateGuardrails({ + orgId: 'o1', + step: { tool: 'resume_campaign', input: { campaignId: 'c1' } }, + tool: tool('resume_campaign', 'low'), + }) + expect(mockedPrisma.report.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ level: 'account' }), + }) + ) + }) + + it('fail-closed: blocks if the DB read throws', async () => { + mockedPrisma.guardrail.findMany.mockResolvedValueOnce([ + { rule: 'pilot_budget_cap', config: JSON.stringify({ pilotStartDate: '2026-01-01' }) }, + ]) + mockedPrisma.report.findMany.mockRejectedValueOnce(new Error('db down')) + const results = await evaluateGuardrails({ + orgId: 'o1', + step: { tool: 'resume_campaign', input: { campaignId: 'c1' } }, + tool: tool('resume_campaign', 'low'), + }) + const r = results.filter((r) => r.rule === 'pilot_budget_cap').find((r) => !r.pass) + expect(r?.pass).toBe(false) + expect(r?.reason).toContain('fail-closed') + }) +}) + +describe('skan_maturity', () => { + it('not applicable to an irrelevant tool', async () => { + const results = await evaluateGuardrails({ + orgId: 'o1', + step: { tool: 'pause_campaign', input: { campaignId: 'c1' } }, + tool: tool('pause_campaign', 'low'), + }) + expect(results.find((r) => r.rule === 'skan_maturity')?.pass).toBe(true) + }) + + it('passes non-SKAN campaigns (e.g. google platform)', async () => { + mockedPrisma.campaign.findFirst.mockResolvedValue({ + platform: 'google', + objective: 'app_install', + startDate: new Date(), + managedByAgent: true, + }) + const results = await evaluateGuardrails({ + orgId: 'o1', + step: { tool: 'adjust_bid', input: { campaignId: 'c1', newBidUsd: 5 } }, + tool: tool('adjust_bid', 'low'), + }) + expect(results.find((r) => r.rule === 'skan_maturity')?.pass).toBe(true) + }) + + it('fail-closed rejects SKAN campaign with unknown startDate', async () => { + mockedPrisma.campaign.findFirst.mockResolvedValue({ + platform: 'meta', + objective: 'app_install', + startDate: null, + managedByAgent: true, + }) + const results = await evaluateGuardrails({ + orgId: 'o1', + step: { tool: 'adjust_bid', input: { campaignId: 'c1', newBidUsd: 5 } }, + tool: tool('adjust_bid', 'low'), + }) + expect(results.find((r) => r.rule === 'skan_maturity')?.pass).toBe(false) + }) + + it('rejects SKAN campaign younger than 72h', async () => { + mockedPrisma.campaign.findFirst.mockResolvedValue({ + platform: 'meta', + objective: 'app_install', + startDate: new Date(Date.now() - 10 * 60 * 60 * 1000), // 10h old + managedByAgent: true, + }) + const results = await evaluateGuardrails({ + orgId: 'o1', + step: { tool: 'adjust_bid', input: { campaignId: 'c1', newBidUsd: 5 } }, + tool: tool('adjust_bid', 'low'), + }) + expect(results.find((r) => r.rule === 'skan_maturity')?.pass).toBe(false) + }) + + it('warns (does not reject) in learning window (day 3) under 2x daily cap', async () => { + mockedPrisma.campaign.findFirst.mockResolvedValue({ + platform: 'meta', + objective: 'app_install', + startDate: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000), // 3 days old + managedByAgent: true, + }) + mockedPrisma.budget.findFirst.mockResolvedValueOnce({ amount: 100 }) + mockedPrisma.report.findMany.mockResolvedValueOnce([{ spend: 50 }]) + const results = await evaluateGuardrails({ + orgId: 'o1', + step: { tool: 'adjust_bid', input: { campaignId: 'c1', newBidUsd: 5 } }, + tool: tool('adjust_bid', 'low'), + }) + const r = results.find((r) => r.rule === 'skan_maturity') + expect(r?.pass).toBe(true) + }) + + it('rejects learning-window spend over 2x daily cap', async () => { + mockedPrisma.campaign.findFirst.mockResolvedValue({ + platform: 'meta', + objective: 'app_install', + startDate: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000), + managedByAgent: true, + }) + mockedPrisma.budget.findFirst.mockResolvedValueOnce({ amount: 100 }) + mockedPrisma.report.findMany.mockResolvedValueOnce([{ spend: 250 }]) + const results = await evaluateGuardrails({ + orgId: 'o1', + step: { tool: 'adjust_bid', input: { campaignId: 'c1', newBidUsd: 5 } }, + tool: tool('adjust_bid', 'low'), + }) + expect(results.find((r) => r.rule === 'skan_maturity')?.pass).toBe(false) + }) + + it('passes SKAN campaigns older than 7 days without further restriction', async () => { + mockedPrisma.campaign.findFirst.mockResolvedValue({ + platform: 'tiktok', + objective: 'app_install', + startDate: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000), + managedByAgent: true, + }) + const results = await evaluateGuardrails({ + orgId: 'o1', + step: { tool: 'adjust_bid', input: { campaignId: 'c1', newBidUsd: 5 } }, + tool: tool('adjust_bid', 'low'), + }) + expect(results.find((r) => r.rule === 'skan_maturity')?.pass).toBe(true) + }) +}) + +describe('tier_cac_ceiling', () => { + it('passes a non-increase input (bid decrease)', async () => { + const results = await evaluateGuardrails({ + orgId: 'o1', + step: { tool: 'adjust_bid', input: { campaignId: 'c1', newBidUsd: 3, previousBidUsd: 5 } }, + tool: tool('adjust_bid', 'low'), + }) + expect(results.find((r) => r.rule === 'tier_cac_ceiling')?.pass).toBe(true) + }) + + it('passes an increase with no derivable channel', async () => { + mockedPrisma.campaign.findFirst.mockResolvedValue({ platform: 'google', objective: 'web_conversion', managedByAgent: true }) + const results = await evaluateGuardrails({ + orgId: 'o1', + step: { tool: 'adjust_bid', input: { campaignId: 'c1', newBidUsd: 5 } }, + tool: tool('adjust_bid', 'low'), + }) + expect(results.find((r) => r.rule === 'tier_cac_ceiling')?.pass).toBe(true) + }) + + it('passes with a warn reason when no CohortSnapshot found for a derivable channel', async () => { + mockedPrisma.campaign.findFirst.mockResolvedValue({ platform: 'meta', objective: 'app_install', managedByAgent: true }) + mockedPrisma.cohortSnapshot.findFirst.mockResolvedValueOnce(null) + const results = await evaluateGuardrails({ + orgId: 'o1', + step: { tool: 'adjust_bid', input: { campaignId: 'c1', newBidUsd: 5 } }, + tool: tool('adjust_bid', 'low'), + }) + const r = results.find((r) => r.rule === 'tier_cac_ceiling') + expect(r?.pass).toBe(true) + expect(r?.reason).toBeDefined() + }) + + it('passes when CohortSnapshot.cac is within ceiling', async () => { + mockedPrisma.campaign.findFirst.mockResolvedValue({ platform: 'meta', objective: 'app_install', managedByAgent: true }) + mockedPrisma.cohortSnapshot.findFirst.mockResolvedValueOnce({ cac: 20 }) + const results = await evaluateGuardrails({ + orgId: 'o1', + step: { tool: 'adjust_bid', input: { campaignId: 'c1', newBidUsd: 5 } }, + tool: tool('adjust_bid', 'low'), + }) + expect(results.find((r) => r.rule === 'tier_cac_ceiling')?.pass).toBe(true) + }) + + it('rejects when CohortSnapshot.cac exceeds the ceiling (firstMonthNet default 8.5 × 5 = 42.5)', async () => { + mockedPrisma.campaign.findFirst.mockResolvedValue({ platform: 'meta', objective: 'app_install', managedByAgent: true }) + mockedPrisma.cohortSnapshot.findFirst.mockResolvedValueOnce({ cac: 50 }) + const results = await evaluateGuardrails({ + orgId: 'o1', + step: { tool: 'adjust_bid', input: { campaignId: 'c1', newBidUsd: 5 } }, + tool: tool('adjust_bid', 'low'), + }) + expect(results.find((r) => r.rule === 'tier_cac_ceiling')?.pass).toBe(false) + }) +}) diff --git a/src/lib/agent/guardrails.ts b/src/lib/agent/guardrails.ts index 1678477..6fc4506 100644 --- a/src/lib/agent/guardrails.ts +++ b/src/lib/agent/guardrails.ts @@ -7,6 +7,14 @@ */ import { prisma } from '@/lib/prisma' import type { ProposedDecisionStep, ToolDefinition } from './types' +import { + evaluatePilotBudget, + tierCacCeiling, + withinCacCeiling, + LEARNING_PHASE_DAYS, + LEARNING_PHASE_HARD_MULTIPLE, +} from '@/lib/growth/budget-guard' +import { FIRST_MONTH_NET_DEFAULT } from '@/lib/growth/pilot-gates' export type GuardrailEvalResult = { pass: boolean @@ -321,6 +329,213 @@ const evaluators: Record = { } return { pass: true, rule: 'requires_approval_above_spend' } }, + + /** + * Growth pilot ($5K cap) — hard org-wide spend ceiling for any step that + * increases spend. Inert unless an org explicitly sets `pilotStartDate` in + * this rule's config (there is no default pilotStartDate) — this keeps + * existing non-pilot customers unaffected. See src/lib/growth/budget-guard.ts. + */ + pilot_budget_cap: async (ctx, config) => { + if (!isSpendIncreaseTool(ctx.step)) return { pass: true, rule: 'pilot_budget_cap' } + const cfg = (config || {}) as { pilotStartDate?: string; capTotal?: number } + if (!cfg.pilotStartDate) return { pass: true, rule: 'pilot_budget_cap' } + const since = new Date(cfg.pilotStartDate) + // level:'account' — sync writes BOTH an account-level row (the contract, + // one per platform per day) AND best-effort per-campaign rows for the + // same spend (src/lib/sync/report-writer.ts). Summing all levels double- + // counts and trips the cap at ~half real spend. + const reports = await prisma.report.findMany({ + where: { orgId: ctx.orgId, level: 'account', date: { gte: since } }, + select: { spend: true }, + }) + const cumulativeSpend = reports.reduce((s, r) => s + r.spend, 0) + const result = evaluatePilotBudget({ cumulativeSpend, capTotal: cfg.capTotal }) + if (result.action === 'auto_pause') { + return { + pass: false, + rule: 'pilot_budget_cap', + reason: `pilot spend ${(result.pct * 100).toFixed(0)}% of cap: ${result.reasons.join('; ')}`, + config, + } + } + if (result.action === 'warn') { + return { + pass: true, + rule: 'pilot_budget_cap', + reason: `pilot spend ${(result.pct * 100).toFixed(0)}% of cap: ${result.reasons.join('; ')}`, + } + } + return { pass: true, rule: 'pilot_budget_cap' } + }, + + /** + * SKAN-attributed iOS install channels (meta/tiktok app_install) have + * delayed, low-trust attribution for the first 72h, and are only + * "learning phase" quality through day 7. Reject automated adjustments on + * a too-young campaign; warn (don't block) during the learning window + * unless spend is running away. + */ + skan_maturity: async (ctx, _config) => { + const skanTools = new Set([ + 'adjust_bid', + 'adjust_daily_budget', + 'adjust_targeting_geo', + 'adjust_targeting_demo', + 'enable_smart_bidding', + ]) + if (!skanTools.has(ctx.step.tool)) return { pass: true, rule: 'skan_maturity' } + const cid = (ctx.step.input as Record).campaignId + if (typeof cid !== 'string') return { pass: true, rule: 'skan_maturity' } + const campaign = await prisma.campaign.findFirst({ + where: { id: cid, orgId: ctx.orgId }, + select: { platform: true, objective: true, startDate: true }, + }) + if (!campaign) return { pass: true, rule: 'skan_maturity' } + const channel = deriveSkanChannel(campaign.platform, campaign.objective) + if (!channel) return { pass: true, rule: 'skan_maturity' } + + if (!campaign.startDate) { + return { + pass: false, + rule: 'skan_maturity', + reason: 'campaign startDate unknown — cannot verify SKAN maturity', + } + } + const ageHours = (Date.now() - campaign.startDate.getTime()) / 3_600_000 + const ageDays = ageHours / 24 + + if (ageHours < 72) { + return { + pass: false, + rule: 'skan_maturity', + reason: `campaign age ${ageHours.toFixed(1)}h < 72h — SKAN data untrusted`, + } + } + + if (ageDays <= LEARNING_PHASE_DAYS) { + const budget = await prisma.budget.findFirst({ + where: { campaignId: cid, type: 'daily' }, + orderBy: { createdAt: 'desc' }, + }) + if (!budget) { + return { + pass: false, + rule: 'skan_maturity', + reason: 'no daily budget on record to verify learning-phase spend', + } + } + const startOfToday = new Date() + startOfToday.setUTCHours(0, 0, 0, 0) + const todayReports = await prisma.report.findMany({ + where: { campaignId: cid, date: { gte: startOfToday } }, + select: { spend: true }, + }) + const spendToday = todayReports.reduce((s, r) => s + r.spend, 0) + const multiple = budget.amount > 0 ? spendToday / budget.amount : 0 + if (multiple > LEARNING_PHASE_HARD_MULTIPLE) { + return { + pass: false, + rule: 'skan_maturity', + reason: `learning-phase spend ${multiple.toFixed(1)}x daily cap > ${LEARNING_PHASE_HARD_MULTIPLE}x`, + } + } + return { + pass: true, + rule: 'skan_maturity', + reason: `campaign in learning phase (day ${ageDays.toFixed(1)}) — proceed with caution`, + } + } + + return { pass: true, rule: 'skan_maturity' } + }, + + /** + * Reject bid/budget increases whose channel's most recent CohortSnapshot + * CAC exceeds the tier ceiling (firstMonthNet × SCALE_PAYBACK_MULTIPLE). + * Not fail-closed: missing CohortSnapshot data is a "don't know", not a + * violation, so it never blocks. + */ + tier_cac_ceiling: async (ctx, config) => { + if (ctx.step.tool !== 'adjust_bid' && ctx.step.tool !== 'adjust_daily_budget') + return { pass: true, rule: 'tier_cac_ceiling' } + if (!isSpendIncreaseTool(ctx.step)) return { pass: true, rule: 'tier_cac_ceiling' } + const cfg = (config || {}) as { firstMonthNet?: number } + const cid = (ctx.step.input as Record).campaignId + if (typeof cid !== 'string') return { pass: true, rule: 'tier_cac_ceiling' } + const campaign = await prisma.campaign.findFirst({ + where: { id: cid, orgId: ctx.orgId }, + select: { platform: true, objective: true }, + }) + if (!campaign) return { pass: true, rule: 'tier_cac_ceiling' } + const channel = deriveSkanChannel(campaign.platform, campaign.objective) + if (!channel) return { pass: true, rule: 'tier_cac_ceiling' } + + const snapshot = await prisma.cohortSnapshot.findFirst({ + where: { orgId: ctx.orgId, channel }, + orderBy: { cohortDate: 'desc' }, + }) + if (!snapshot || snapshot.cac == null) { + return { + pass: true, + rule: 'tier_cac_ceiling', + reason: `no CohortSnapshot data for channel ${channel} — cannot verify CAC ceiling`, + } + } + const firstMonthNet = cfg.firstMonthNet ?? FIRST_MONTH_NET_DEFAULT + const ceiling = tierCacCeiling(firstMonthNet) + if (!withinCacCeiling(snapshot.cac, ceiling)) { + return { + pass: false, + rule: 'tier_cac_ceiling', + reason: `CAC $${snapshot.cac.toFixed(2)} > tier ceiling $${ceiling.toFixed(2)} (channel ${channel})`, + config, + } + } + return { pass: true, rule: 'tier_cac_ceiling' } + }, +} + +/** + * True for tools that increase spend, used by pilot_budget_cap and + * tier_cac_ceiling. Conservative: if a "previous" value isn't present to + * compare against, treat it as an increase. + */ +function isSpendIncreaseTool(step: ProposedDecisionStep): boolean { + const input = step.input as Record + switch (step.tool) { + case 'adjust_daily_budget': { + const next = Number(input.newDailyBudget) + const prev = Number(input.previousDailyBudget) + if (!Number.isFinite(next)) return false + if (!Number.isFinite(prev)) return true + return next > prev + } + case 'adjust_bid': { + const next = Number(input.newBidUsd) + const prev = Number(input.previousBidUsd) + if (!Number.isFinite(next)) return false + if (!Number.isFinite(prev)) return true + return next > prev + } + case 'resume_campaign': + case 'enable_smart_bidding': + case 'clone_campaign': + return true + default: + return false + } +} + +/** + * Map Campaign(platform, objective) to the growth channel taxonomy's two + * SKAN-attributed iOS channels. Returns null for anything else — those + * campaigns aren't SKAN and this rule doesn't apply. + */ +function deriveSkanChannel(platform: string, objective: string | null): 'paid_meta_ios' | 'paid_tiktok_ios' | null { + if (platform === 'meta' && objective === 'app_install') return 'paid_meta_ios' + if (platform === 'tiktok' && objective === 'app_install') return 'paid_tiktok_ios' + return null } type BuiltinDef = { rule: string; config: unknown; failClosed?: boolean } @@ -337,6 +552,9 @@ const BUILTIN_DEFAULTS: BuiltinDef[] = [ { rule: 'cooldown', config: { hours: 4 } }, { rule: 'pause_only_with_conversions', config: { minSpendThreshold: 50, minImpressionsForSignal: 2000 } }, { rule: 'max_per_day', config: { max: 20 } }, + { rule: 'pilot_budget_cap', config: {}, failClosed: true }, + { rule: 'skan_maturity', config: {}, failClosed: true }, + { rule: 'tier_cac_ceiling', config: {}, failClosed: false }, ] const FAIL_CLOSED_RULES = new Set( diff --git a/src/lib/agent/plan.ts b/src/lib/agent/plan.ts index c176279..bfe1ec2 100644 --- a/src/lib/agent/plan.ts +++ b/src/lib/agent/plan.ts @@ -165,6 +165,7 @@ export async function plan( TOOL_CATALOG_JSON: JSON.stringify(toolCatalogForPrompt(), null, 2), RECENT_DECISIONS_JSON: '', GUARDRAIL_HINTS: '', + GROWTH_JSON: '', CAMPAIGNS_JSON: '', }) const volatileBody = renderPrompt(split.volatileMarker, { @@ -174,6 +175,9 @@ export async function plan( snapshot.guardrailHints.length === 0 ? '(none configured — only built-in defaults apply)' : snapshot.guardrailHints.join('\n'), + GROWTH_JSON: snapshot.growth + ? JSON.stringify(snapshot.growth, null, 2) + : '(none — no growth/cohort data for this org)', CAMPAIGNS_JSON: JSON.stringify(snapshot.campaigns, null, 2), }) diff --git a/src/lib/agent/prompts/loader.test.ts b/src/lib/agent/prompts/loader.test.ts index ceb9e9b..d6c03f9 100644 --- a/src/lib/agent/prompts/loader.test.ts +++ b/src/lib/agent/prompts/loader.test.ts @@ -1,3 +1,4 @@ +import { readFile } from 'node:fs/promises' import { describe, it, expect } from 'vitest' import { orgBucket, renderPrompt } from './loader' @@ -38,6 +39,21 @@ describe('orgBucket', () => { }) }) +describe('plan.v1.md template shape', () => { + // plan.ts splits the template on this marker for prompt caching: everything + // before it is the stable (cached) half and gets empty vars. Volatile data + // slots must therefore sit AFTER the marker or they silently render empty. + it('keeps volatile placeholders after the cache-split marker', async () => { + const template = await readFile(new URL('./plan.v1.md', import.meta.url), 'utf-8') + const splitIdx = template.indexOf('## Recent decisions') + expect(splitIdx).toBeGreaterThan(0) + for (const slot of ['{{RECENT_DECISIONS_JSON}}', '{{GUARDRAIL_HINTS}}', '{{GROWTH_JSON}}', '{{CAMPAIGNS_JSON}}']) { + expect(template.indexOf(slot)).toBeGreaterThan(splitIdx) + } + expect(template.indexOf('{{TOOL_CATALOG_JSON}}')).toBeLessThan(splitIdx) + }) +}) + describe('renderPrompt', () => { it('substitutes {{KEY}} placeholders', () => { expect(renderPrompt('Hello {{NAME}}!', { NAME: 'world' })).toBe('Hello world!') diff --git a/src/lib/agent/prompts/plan.v1.md b/src/lib/agent/prompts/plan.v1.md index e5ab023..9bcae09 100644 --- a/src/lib/agent/prompts/plan.v1.md +++ b/src/lib/agent/prompts/plan.v1.md @@ -1,9 +1,11 @@ You are Adex Agent, an autonomous ad-operations assistant. ## Your job + Look at recent campaign performance and decide whether to take any actions. You may propose 0–5 decisions per cycle. **Quality over quantity.** A single well-justified decision beats five guesses. ## Hard rules + - You may ONLY call tools from the catalog below. Inventing tool names is a critical failure. - You MUST return strict JSON matching the schema at the bottom — no prose, no markdown, no comments. - For every step, include both the tool name and a short `reason` explaining *why this campaign, why this tool, why now*. @@ -13,6 +15,7 @@ Look at recent campaign performance and decide whether to take any actions. You - Never reference campaigns or ad-groups not present in the perceive context — IDs are validated server-side. ## How to think (very brief) + 1. **Skim** the campaign list. What's burning money with no return? What's outperforming? 2. **Pick at most 1–3 issues** that justify acting RIGHT NOW. Most cycles will have nothing to do. 3. **Pick the smallest reversible step** that addresses each issue. @@ -20,27 +23,40 @@ Look at recent campaign performance and decide whether to take any actions. You 5. **Use `noop`** when nothing rises to the level of action. ## Severity + - `info` — informational only, no action recommended (`noop`) - `opportunity` — favorable trend, scale up - `warning` — degrading metric, intervene before it gets worse - `alert` — active waste / outage; stop the bleeding ## Tool catalog + {{TOOL_CATALOG_JSON}} ## Recent decisions (for short-term memory; do not repeat) + {{RECENT_DECISIONS_JSON}} ## Active guardrails (advisory hints — server enforces hard limits) + {{GUARDRAIL_HINTS}} +## Growth funnel & pilot status (per-channel gates; `(none)` for non-growth orgs) + +{{GROWTH_JSON}} + +- `channels[].gate` is pilot discipline: `kill`/`halve` → only downward steps (pause, budget/bid cuts) for that channel; `scale` → scaling still requires a human decision, propose it via `propose_paid_gate_change`, never raise budgets/bids directly. +- If `budget.action` is not `ok`, do not propose any step that increases spend on any paid channel. + ## Campaigns (last 7d + last 24h) + {{CAMPAIGNS_JSON}} ## Output schema + Return JSON of this exact shape: -``` +```json { "decisions": [ { @@ -56,6 +72,6 @@ Return JSON of this exact shape: If nothing to do: -``` +```json { "decisions": [{ "rationale": "Healthy", "severity": "info", "steps": [{ "tool": "noop", "input": {}, "reason": "All campaigns within targets" }] }] } ``` diff --git a/src/lib/dashboard-bi.test.ts b/src/lib/dashboard-bi.test.ts new file mode 100644 index 0000000..727ef59 --- /dev/null +++ b/src/lib/dashboard-bi.test.ts @@ -0,0 +1,314 @@ +import { describe, expect, it } from 'vitest' +import { + quickRangeDates, + formatDateRangeLabel, + formatMoneyOrDash, + formatCountOrDash, + formatPercentOrDash, + formatRoiOrDash, + buildQueryString, + mergeFieldConfig, + toggleFieldVisibility, + reorderFields, + visibleKeys, + collectDistinct, + filterBreakdownRows, + aggregateBreakdownRows, + summarizeSummaryRows, + type BreakdownRow, + type FieldToggle, + type SummaryRow, +} from './dashboard-bi' + +describe('quickRangeDates', () => { + const today = new Date('2026-07-07T12:00:00Z') + + it('7d is inclusive of today, spanning 7 calendar days', () => { + expect(quickRangeDates('7d', today)).toEqual({ start: '2026-07-01', end: '2026-07-07' }) + }) + + it('14d', () => { + expect(quickRangeDates('14d', today)).toEqual({ start: '2026-06-24', end: '2026-07-07' }) + }) + + it('30d', () => { + expect(quickRangeDates('30d', today)).toEqual({ start: '2026-06-08', end: '2026-07-07' }) + }) +}) + +describe('formatDateRangeLabel', () => { + it('collapses equal start/end to a single date', () => { + expect(formatDateRangeLabel('2026-07-01', '2026-07-01')).toBe('2026-07-01') + }) + it('renders a range with an en dash', () => { + expect(formatDateRangeLabel('2026-07-01', '2026-07-07')).toBe('2026-07-01 – 2026-07-07') + }) + it('handles empty input', () => { + expect(formatDateRangeLabel('', '')).toBe('—') + }) +}) + +describe('formatters never fabricate missing data', () => { + it('formatMoneyOrDash', () => { + expect(formatMoneyOrDash(1234.5)).toBe('$1,234.50') + expect(formatMoneyOrDash(null)).toBe('—') + expect(formatMoneyOrDash(undefined)).toBe('—') + expect(formatMoneyOrDash(NaN)).toBe('—') + }) + it('formatCountOrDash', () => { + expect(formatCountOrDash(12345)).toBe('12,345') + expect(formatCountOrDash(null)).toBe('—') + }) + it('formatPercentOrDash treats input as a 0..1 ratio', () => { + expect(formatPercentOrDash(0.1234)).toBe('12.3%') + expect(formatPercentOrDash(null)).toBe('—') + }) + it('formatRoiOrDash', () => { + expect(formatRoiOrDash(1.5)).toBe('1.50x') + expect(formatRoiOrDash(null)).toBe('—') + }) +}) + +describe('buildQueryString', () => { + it('drops null/undefined/empty values', () => { + expect(buildQueryString({ a: '1', b: null, c: undefined, d: '' })).toBe('?a=1') + }) + it('returns empty string when nothing set', () => { + expect(buildQueryString({ a: null })).toBe('') + }) +}) + +describe('mergeFieldConfig', () => { + it('falls back to all-hidden when nothing stored', () => { + expect(mergeFieldConfig(['a', 'b'], null)).toEqual([ + { key: 'a', visible: false }, + { key: 'b', visible: false }, + ]) + }) + it('keeps stored order and visibility for known keys', () => { + const stored: FieldToggle[] = [{ key: 'b', visible: true }, { key: 'a', visible: false }] + expect(mergeFieldConfig(['a', 'b'], stored)).toEqual(stored) + }) + it('drops stored keys no longer known, appends new keys as hidden', () => { + const stored: FieldToggle[] = [{ key: 'gone', visible: true }, { key: 'a', visible: true }] + expect(mergeFieldConfig(['a', 'b'], stored)).toEqual([ + { key: 'a', visible: true }, + { key: 'b', visible: false }, + ]) + }) +}) + +describe('toggleFieldVisibility', () => { + it('flips only the matching key', () => { + const fields: FieldToggle[] = [{ key: 'a', visible: false }, { key: 'b', visible: true }] + expect(toggleFieldVisibility(fields, 'a')).toEqual([ + { key: 'a', visible: true }, + { key: 'b', visible: true }, + ]) + }) +}) + +describe('reorderFields', () => { + const fields: FieldToggle[] = [{ key: 'a', visible: true }, { key: 'b', visible: true }, { key: 'c', visible: true }] + + it('moves an item forward', () => { + expect(reorderFields(fields, 0, 2).map((f) => f.key)).toEqual(['b', 'c', 'a']) + }) + it('moves an item backward', () => { + expect(reorderFields(fields, 2, 0).map((f) => f.key)).toEqual(['c', 'a', 'b']) + }) + it('no-ops on out-of-range indices', () => { + expect(reorderFields(fields, 0, 5)).toBe(fields) + expect(reorderFields(fields, -1, 1)).toBe(fields) + }) +}) + +describe('visibleKeys', () => { + it('filters to visible, in order', () => { + const fields: FieldToggle[] = [{ key: 'a', visible: false }, { key: 'b', visible: true }, { key: 'c', visible: true }] + expect(visibleKeys(fields)).toEqual(['b', 'c']) + }) +}) + +const row = (over: Partial = {}): BreakdownRow => ({ + date: '2026-07-01', + os: 'ios', + platform: 'meta', + agency: 'acme', + impressions: 1000, + clicks: 100, + spend: 50, + cpc: 0.5, + signups: null, + costPerSignup: null, + d1Rate: null, + d7Rate: null, + d0Roi: null, + d7Roi: null, + ...over, +}) + +describe('collectDistinct', () => { + it('collects sorted unique non-empty string values', () => { + const rows = [row({ platform: 'meta' }), row({ platform: 'tiktok' }), row({ platform: 'meta' })] + expect(collectDistinct(rows, 'platform')).toEqual(['meta', 'tiktok']) + }) + it('excludes null', () => { + const rows = [row({ agency: null }), row({ agency: 'acme' })] + expect(collectDistinct(rows, 'agency')).toEqual(['acme']) + }) +}) + +describe('filterBreakdownRows', () => { + const rows = [ + row({ platform: 'meta', agency: 'acme' }), + row({ platform: 'tiktok', agency: 'acme' }), + row({ platform: 'meta', agency: 'other' }), + ] + + it('empty selections mean no filter', () => { + expect(filterBreakdownRows(rows, [], [])).toHaveLength(3) + }) + it('filters by platform', () => { + expect(filterBreakdownRows(rows, ['tiktok'], [])).toHaveLength(1) + }) + it('filters by agency', () => { + expect(filterBreakdownRows(rows, [], ['other'])).toHaveLength(1) + }) + it('combines both filters (AND)', () => { + expect(filterBreakdownRows(rows, ['meta'], ['other'])).toHaveLength(1) + }) +}) + +describe('aggregateBreakdownRows', () => { + it('sums metrics across dates within the same os|platform|agency group and recomputes cpc from totals', () => { + const rows = [ + row({ date: '2026-07-01', platform: 'meta', agency: 'acme', impressions: 1000, clicks: 100, spend: 50 }), + row({ date: '2026-07-02', platform: 'meta', agency: 'acme', impressions: 2000, clicks: 300, spend: 60 }), + ] + const agg = aggregateBreakdownRows(rows, '2026-07-01 – 2026-07-02') + expect(agg).toHaveLength(1) + expect(agg[0]).toMatchObject({ + dateLabel: '2026-07-01 – 2026-07-02', + platform: 'meta', + agency: 'acme', + impressions: 3000, + clicks: 400, + spend: 110, + }) + expect(agg[0].cpc).toBeCloseTo(110 / 400) + }) + + it('keeps distinct os|platform|agency groups separate', () => { + const rows = [row({ platform: 'meta' }), row({ platform: 'tiktok' })] + expect(aggregateBreakdownRows(rows, 'x')).toHaveLength(2) + }) + + it('cpc is null when clicks sum to 0', () => { + const rows = [row({ clicks: 0, spend: 10 })] + expect(aggregateBreakdownRows(rows, 'x')[0].cpc).toBeNull() + }) + + it('sums signups across joined rows and recomputes costPerSignup from the sums', () => { + const rows = [ + row({ date: '2026-07-01', spend: 50, signups: 10, costPerSignup: 5 }), + row({ date: '2026-07-02', spend: 60, signups: 20, costPerSignup: 3 }), + ] + const agg = aggregateBreakdownRows(rows, 'x') + expect(agg[0].signups).toBe(30) + expect(agg[0].costPerSignup).toBeCloseTo(110 / 30) + }) + + it('signups/costPerSignup stay null when no row in the group joined', () => { + const rows = [row({ signups: null, costPerSignup: null })] + expect(aggregateBreakdownRows(rows, 'x')[0].signups).toBeNull() + expect(aggregateBreakdownRows(rows, 'x')[0].costPerSignup).toBeNull() + }) + + it('mixed joined/unjoined group: costPerSignup uses joined-days spend only', () => { + // Day 1 joined ($50 spend, 10 signups); day 2 did NOT join ($60 spend, + // signups unknown). Total spend still reports $110, but costPerSignup + // must be 50/10 — dividing all-days spend by joined-only signups would + // inflate it to 11 (the funnelJoin:'partial' silent-failure bug). + const rows = [ + row({ date: '2026-07-01', spend: 50, signups: 10, costPerSignup: 5 }), + row({ date: '2026-07-02', spend: 60, signups: null, costPerSignup: null }), + ] + const agg = aggregateBreakdownRows(rows, 'x')[0] + expect(agg.spend).toBe(110) + expect(agg.signups).toBe(10) + expect(agg.costPerSignup).toBeCloseTo(5) + }) + + it('d1Rate/d7Rate/d0Roi/d7Roi are always null in aggregate mode (not re-derivable across dates)', () => { + const rows = [row({ d1Rate: 0.3, d7Rate: 0.2, d0Roi: 1.1, d7Roi: 1.4 })] + const agg = aggregateBreakdownRows(rows, 'x')[0] + expect(agg.d1Rate).toBeNull() + expect(agg.d7Rate).toBeNull() + expect(agg.d0Roi).toBeNull() + expect(agg.d7Roi).toBeNull() + }) +}) + +const summaryRow = (over: Partial = {}): SummaryRow => ({ + os: 'ios', + source: 'paid', + spend: 100, + signups: 10, + costPerSignup: 10, + d1Rate: 0.5, + d7Rate: 0.3, + d0Roi: 1.2, + d7Roi: 1.5, + subscriptionRate: 0.1, + arpu7d: 2, + arppu7d: 20, + trialToPaidRateApprox: 0.2, + trials: 5, + subscribers: 2, + costPerPayingUser: 50, + revenueD0: 3, + revenueD7: 8, + revenueToDate: 15, + ...over, +}) + +describe('summarizeSummaryRows', () => { + it('sums counts and re-derives cost-per-* from the sums', () => { + const rows = [ + summaryRow({ spend: 100, signups: 10, trials: 5, subscribers: 2 }), + summaryRow({ spend: 200, signups: 20, trials: 10, subscribers: 8 }), + ] + const totals = summarizeSummaryRows(rows) + expect(totals.spend).toBe(300) + expect(totals.signups).toBe(30) + expect(totals.trials).toBe(15) + expect(totals.subscribers).toBe(10) + expect(totals.costPerSignup).toBeCloseTo(10) + expect(totals.costPerPayingUser).toBeCloseTo(30) + }) + + it('sums the revenue window columns as plain totals', () => { + const rows = [ + summaryRow({ revenueD0: 3, revenueD7: 8, revenueToDate: 15 }), + summaryRow({ revenueD0: 7, revenueD7: 12, revenueToDate: 25 }), + ] + const totals = summarizeSummaryRows(rows) + expect(totals.revenueD0).toBe(10) + expect(totals.revenueD7).toBe(20) + expect(totals.revenueToDate).toBe(40) + }) + + it('spend is null when every row lacks spend data (never coerces to 0)', () => { + const rows = [summaryRow({ spend: null }), summaryRow({ spend: null })] + const totals = summarizeSummaryRows(rows) + expect(totals.spend).toBeNull() + expect(totals.costPerSignup).toBeNull() + expect(totals.costPerPayingUser).toBeNull() + }) + + it('sums spend across rows even when some rows lack it', () => { + const rows = [summaryRow({ spend: 100 }), summaryRow({ spend: null })] + expect(summarizeSummaryRows(rows).spend).toBe(100) + }) +}) diff --git a/src/lib/dashboard-bi.ts b/src/lib/dashboard-bi.ts new file mode 100644 index 0000000..05b7d25 --- /dev/null +++ b/src/lib/dashboard-bi.ts @@ -0,0 +1,346 @@ +/** + * Pure helpers backing the /dashboard BI view (src/app/(dashboard)/dashboard/_client.tsx). + * + * Kept dependency-free from React so it's directly unit-testable with vitest. + * Formatting here intentionally never fabricates a number the API didn't + * return — callers pass `null`/`undefined` through and get "—" back rather + * than a derived guess (see AGENTS.md's "don't invent a semantics the backend + * doesn't own" convention, applied to the funnel-join-pending breakdown cols). + */ + +import { + cpc as calcCpc, + costPerSignup as calcCostPerSignup, + costPerPayingUser as calcCostPerPayingUser, +} from '@/lib/growth/kpi-canon' + +// ───────────────────────────── date range ───────────────────────────── + +export type QuickRange = '7d' | '14d' | '30d' | 'custom' + +const QUICK_RANGE_DAYS: Record, number> = { + '7d': 7, + '14d': 14, + '30d': 30, +} + +/** YYYY-MM-DD in UTC — matches how the API parses `start`/`end` with `new Date(str)`. */ +export function toISODate(d: Date): string { + return d.toISOString().slice(0, 10) +} + +/** + * Resolve a quick-range preset to concrete start/end dates, inclusive of + * `today`. `today` defaults to `new Date()` but is a parameter so tests are + * deterministic. + */ +export function quickRangeDates(range: Exclude, today: Date = new Date()): { start: string; end: string } { + const days = QUICK_RANGE_DAYS[range] + const end = new Date(today) + const start = new Date(today) + start.setUTCDate(start.getUTCDate() - (days - 1)) + return { start: toISODate(start), end: toISODate(end) } +} + +/** Human label for the breakdown table's date column when aggregated into one row. */ +export function formatDateRangeLabel(start: string, end: string): string { + if (!start && !end) return '—' + if (start === end) return start || end + return `${start} – ${end}` +} + +// ───────────────────────────── formatting ───────────────────────────── + +const MONEY_FMT = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }) +const INT_FMT = new Intl.NumberFormat('en-US') + +/** `$1,234.56`, or "—" for null/undefined/NaN — never coerces missing data to $0. */ +export function formatMoneyOrDash(n: number | null | undefined): string { + if (n === null || n === undefined || Number.isNaN(n)) return '—' + return MONEY_FMT.format(n) +} + +/** Integer count with thousands separators, or "—" for null/undefined. */ +export function formatCountOrDash(n: number | null | undefined): string { + if (n === null || n === undefined || Number.isNaN(n)) return '—' + return INT_FMT.format(n) +} + +/** `n` is a 0..1 ratio → `x.x%`, or "—" for null/undefined. */ +export function formatPercentOrDash(n: number | null | undefined): string { + if (n === null || n === undefined || Number.isNaN(n)) return '—' + return `${(n * 100).toFixed(1)}%` +} + +/** `n` is already an ROI multiple (revenue/spend) → `x.xxx x`, or "—". */ +export function formatRoiOrDash(n: number | null | undefined): string { + if (n === null || n === undefined || Number.isNaN(n)) return '—' + return `${n.toFixed(2)}x` +} + +// ───────────────────────────── query params ───────────────────────────── + +/** Builds a query string from a param bag, dropping null/undefined/empty-string values. */ +export function buildQueryString(params: Record): string { + const qs = new URLSearchParams() + for (const [k, v] of Object.entries(params)) { + if (v !== null && v !== undefined && v !== '') qs.set(k, v) + } + const s = qs.toString() + return s ? `?${s}` : '' +} + +// ───────────────────────────── field config (drag-orderable optional columns) ───────────────────────────── + +export interface FieldToggle { + key: string + visible: boolean +} + +/** + * Reconciles a stored (e.g. localStorage) field-config list against the + * current full set of optional keys a page knows about: + * - keeps stored order for keys that still exist + * - drops stored keys no longer in `allKeys` (column was removed from the page) + * - appends newly-added keys (not in storage) as hidden, in `allKeys` order + * + * `stored` may be null (nothing persisted yet) — falls back to all-hidden. + */ +export function mergeFieldConfig(allKeys: string[], stored: FieldToggle[] | null): FieldToggle[] { + const known = new Set(allKeys) + const kept = (stored ?? []).filter((f) => known.has(f.key)) + const keptKeys = new Set(kept.map((f) => f.key)) + const added = allKeys.filter((k) => !keptKeys.has(k)).map((key) => ({ key, visible: false })) + return [...kept, ...added] +} + +export function toggleFieldVisibility(fields: FieldToggle[], key: string): FieldToggle[] { + return fields.map((f) => (f.key === key ? { ...f, visible: !f.visible } : f)) +} + +/** Moves the item at `fromIndex` to `toIndex`, used by field-config-bar's drag reorder. */ +// ───────────────────────────── summary table (os × source) totals ───────────────────────────── + +export interface SummaryRow { + os: string + source: string + spend: number | null + signups: number + costPerSignup: number | null + d1Rate: number + d7Rate: number + d0Roi: number | null + d7Roi: number | null + subscriptionRate: number + arpu7d: number + arppu7d: number + trialToPaidRateApprox: number + trials: number + subscribers: number + costPerPayingUser: number | null + revenueD0: number + revenueD7: number + revenueToDate: number +} + +export interface SummaryTotals { + spend: number | null + signups: number + costPerSignup: number | null + trials: number + subscribers: number + costPerPayingUser: number | null + revenueD0: number + revenueD7: number + revenueToDate: number +} + +/** + * Grand-total row for the OS×source summary table. Only sums fields that are + * true counts (spend, signups, trials, subscribers) and re-derives + * cost-per-signup/cost-per-paying-user from those sums — the same formula + * the API itself uses (kpi-canon.costPerSignup/costPerPayingUser). + * + * Rate/ROI columns (d1Rate, d7Rate, d0Roi, d7Roi, subscriptionRate, arpu7d, + * arppu7d, trialToPaidRateApprox) are deliberately NOT aggregated here: the + * API doesn't return the cohort-size/base denominators needed to weight an + * average correctly, and a naive mean-of-rates across os×source buckets + * would misrepresent the true blended rate. Callers should render "—" for + * those columns in the totals row rather than fabricate a number. + */ +export function summarizeSummaryRows(rows: SummaryRow[]): SummaryTotals { + let spendSum = 0 + let hasSpend = false + let signups = 0 + let trials = 0 + let subscribers = 0 + let revenueD0 = 0 + let revenueD7 = 0 + let revenueToDate = 0 + for (const r of rows) { + if (r.spend !== null) { + spendSum += r.spend + hasSpend = true + } + signups += r.signups + trials += r.trials + subscribers += r.subscribers + revenueD0 += r.revenueD0 + revenueD7 += r.revenueD7 + revenueToDate += r.revenueToDate + } + const spend = hasSpend ? spendSum : null + return { + spend, + signups, + costPerSignup: spend !== null ? calcCostPerSignup(spend, signups) : null, + trials, + subscribers, + costPerPayingUser: spend !== null ? calcCostPerPayingUser(spend, subscribers) : null, + revenueD0, + revenueD7, + revenueToDate, + } +} + +export function reorderFields(fields: FieldToggle[], fromIndex: number, toIndex: number): FieldToggle[] { + if (fromIndex === toIndex || fromIndex < 0 || toIndex < 0 || fromIndex >= fields.length || toIndex >= fields.length) { + return fields + } + const next = [...fields] + const [moved] = next.splice(fromIndex, 1) + next.splice(toIndex, 0, moved) + return next +} + +export const visibleKeys = (fields: FieldToggle[]): string[] => fields.filter((f) => f.visible).map((f) => f.key) + +// ───────────────────────────── breakdown table: client-side filter + aggregation ───────────────────────────── + +/** + * A row from GET /api/reports/breakdown. The funnel columns (signups through + * d7Roi) are real numbers when this row's (date, os, platform, agency) key + * joined a CohortSnapshot bucket (bi §7 funnel↔spend bridge), and `null` when + * it didn't — see the route's `funnelJoin` response field (response-level, + * not per-row) for the aggregate join-hit signal driving the "pending" banner. + */ +export interface BreakdownRow { + date: string + os: string | null + platform: string + agency: string | null + impressions: number + clicks: number + spend: number + cpc: number | null + signups: number | null + costPerSignup: number | null + d1Rate: number | null + d7Rate: number | null + d0Roi: number | null + d7Roi: number | null +} + +/** Distinct, non-empty values for `key` across `rows`, sorted ascending. Used to populate dynamic filter options. */ +export function collectDistinct(rows: T[], key: K): string[] { + const set = new Set() + for (const r of rows) { + const v = r[key] + if (typeof v === 'string' && v.length > 0) set.add(v) + } + return [...set].sort() +} + +/** Empty selection means "no filter" (show all) — matches the filter-bar's "全部" state. */ +export function filterBreakdownRows(rows: BreakdownRow[], selectedPlatforms: string[], selectedAgencies: string[]): BreakdownRow[] { + return rows.filter((r) => { + if (selectedPlatforms.length > 0 && !selectedPlatforms.includes(r.platform)) return false + if (selectedAgencies.length > 0 && !selectedAgencies.includes(r.agency ?? '')) return false + return true + }) +} + +export interface AggregatedBreakdownRow { + dateLabel: string + os: string | null + platform: string + agency: string | null + impressions: number + clicks: number + spend: number + cpc: number | null + signups: number | null + costPerSignup: number | null + // Rate columns (d1Rate/d7Rate/d0Roi/d7Roi) are NOT re-derivable once + // collapsed across dates — the API doesn't return the retained/base + // denominators needed to weight a correct blended rate, and averaging + // already-derived per-day rates would misrepresent the true rate (same + // reasoning as summarizeSummaryRows's totals row). aggregateBreakdownRows + // always returns null for these; the type stays `number | null` (rather + // than a `null` literal) so daily-mode rows — which DO carry real + // per-day rates — fit the same display-row shape in _client.tsx. + d1Rate: number | null + d7Rate: number | null + d0Roi: number | null + d7Roi: number | null +} + +/** + * Collapses the date dimension: groups by os|platform|agency and sums + * impressions/clicks/spend/signups, recomputing cpc and costPerSignup from + * the summed totals (never averaging already-derived per-day values). + * `rangeLabel` is the "区间" string shown in the date column (see + * formatDateRangeLabel). + */ +export function aggregateBreakdownRows(rows: BreakdownRow[], rangeLabel: string): AggregatedBreakdownRow[] { + type Agg = { + os: string | null + platform: string + agency: string | null + impressions: number + clicks: number + spend: number + signups: number + /** Spend from joined rows only — the valid costPerSignup denominator's twin. */ + joinedSpend: number + hasSignups: boolean + } + const groups = new Map() + for (const r of rows) { + const key = `${r.os ?? ''}|${r.platform}|${r.agency ?? ''}` + let g = groups.get(key) + if (!g) { + g = { os: r.os, platform: r.platform, agency: r.agency, impressions: 0, clicks: 0, spend: 0, signups: 0, joinedSpend: 0, hasSignups: false } + groups.set(key, g) + } + g.impressions += r.impressions + g.clicks += r.clicks + g.spend += r.spend + if (r.signups !== null) { + g.signups += r.signups + g.joinedSpend += r.spend + g.hasSignups = true + } + } + return [...groups.values()] + .map((g) => ({ + dateLabel: rangeLabel, + os: g.os, + platform: g.platform, + agency: g.agency, + impressions: g.impressions, + clicks: g.clicks, + spend: g.spend, + cpc: calcCpc(g.spend, g.clicks), + signups: g.hasSignups ? g.signups : null, + // Divide joined-days spend by joined-days signups — mixing all-days + // spend with joined-only signups (funnelJoin:'partial') silently + // inflates the KPI, same reason the rate columns below stay null. + costPerSignup: g.hasSignups ? calcCostPerSignup(g.joinedSpend, g.signups) : null, + d1Rate: null, + d7Rate: null, + d0Roi: null, + d7Roi: null, + })) + .sort((x, y) => (x.platform === y.platform ? (x.agency ?? '').localeCompare(y.agency ?? '') : x.platform.localeCompare(y.platform))) +} diff --git a/src/lib/growth/adjust-ingest.test.ts b/src/lib/growth/adjust-ingest.test.ts new file mode 100644 index 0000000..71e3d58 --- /dev/null +++ b/src/lib/growth/adjust-ingest.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from 'vitest' +import { mapAdjustCallback } from './adjust-ingest' +import { EVENTS, SOURCES } from './events' +import { CHANNELS } from './channels' + +const createdAt = '1751500800' // 2025-07-03T00:00:00Z + +describe('mapAdjustCallback', () => { + it('maps an install activity_kind to install', () => { + const r = mapAdjustCallback({ + activity_kind: 'install', + network_name: 'Apple Search Ads', + adid: 'abc123', + created_at: createdAt, + country: 'US', + }) + expect(r).toMatchObject({ + source: SOURCES.ADJUST, + eventName: EVENTS.INSTALL, + userKey: 'adjust:abc123', + channel: CHANNELS.PAID_ASA, + country: 'US', + }) + expect(r?.occurredAt.getTime()).toBe(Number(createdAt) * 1000) + }) + + it('prefers the transmitted RC app_user_id over the adid namespace fallback (decision B)', () => { + const r = mapAdjustCallback({ + activity_kind: 'install', + adid: 'abc123', + app_user_id: 'rc-user-1', + created_at: createdAt, + }) + expect(r?.userKey).toBe('rc-user-1') + }) + + it('maps a mapped event_token via the caller-supplied map', () => { + const r = mapAdjustCallback( + { activity_kind: 'event', event_token: 'abc1', created_at: createdAt, adid: 'x1' }, + { abc1: EVENTS.FIRST_CHAT }, + ) + expect(r?.eventName).toBe(EVENTS.FIRST_CHAT) + }) + + it('drops an event activity_kind with an unmapped token', () => { + const r = mapAdjustCallback( + { activity_kind: 'event', event_token: 'unknown', created_at: createdAt, adid: 'x1' }, + { abc1: EVENTS.FIRST_CHAT }, + ) + expect(r).toBeNull() + }) + + it('drops event without an event_token map at all', () => { + expect(mapAdjustCallback({ activity_kind: 'event', event_token: 'abc1', created_at: createdAt })).toBeNull() + }) + + it('drops reattribution and session activity_kinds', () => { + expect(mapAdjustCallback({ activity_kind: 'reattribution', created_at: createdAt })).toBeNull() + expect(mapAdjustCallback({ activity_kind: 'session', created_at: createdAt })).toBeNull() + }) + + it('drops when created_at is missing or unparseable', () => { + expect(mapAdjustCallback({ activity_kind: 'install', adid: 'x1' })).toBeNull() + expect(mapAdjustCallback({ activity_kind: 'install', adid: 'x1', created_at: 'not-a-number' })).toBeNull() + }) + + it('falls back to null userKey when neither app_user_id nor adid is present', () => { + const r = mapAdjustCallback({ activity_kind: 'install', created_at: createdAt }) + expect(r?.userKey).toBeNull() + }) + + it('resolves channel via resolveAdjustChannel, defaulting unmapped networks to organic', () => { + const r = mapAdjustCallback({ activity_kind: 'install', created_at: createdAt, network_name: 'Some Ad Network' }) + expect(r?.channel).toBe(CHANNELS.ORGANIC) + }) + + it('preserves raw params', () => { + const params = { activity_kind: 'install', created_at: createdAt, adid: 'x1' } + const r = mapAdjustCallback(params) + expect(r?.raw).toBe(params) + }) +}) + +describe('mapAdjustCallback — os (bi §6)', () => { + it('normalizes os_name=ios/android to our Os enum', () => { + expect(mapAdjustCallback({ activity_kind: 'install', created_at: createdAt, adid: 'x1', os_name: 'ios' })?.os).toBe('ios') + expect(mapAdjustCallback({ activity_kind: 'install', created_at: createdAt, adid: 'x1', os_name: 'iOS' })?.os).toBe('ios') + expect(mapAdjustCallback({ activity_kind: 'install', created_at: createdAt, adid: 'x1', os_name: 'android' })?.os).toBe('android') + }) + it('falls back to device_type=web when os_name is absent', () => { + expect(mapAdjustCallback({ activity_kind: 'install', created_at: createdAt, adid: 'x1', device_type: 'web' })?.os).toBe('web') + }) + it('is null when neither os_name nor a recognizable device_type is present', () => { + expect(mapAdjustCallback({ activity_kind: 'install', created_at: createdAt, adid: 'x1' })?.os).toBeNull() + }) +}) diff --git a/src/lib/growth/adjust-ingest.ts b/src/lib/growth/adjust-ingest.ts new file mode 100644 index 0000000..42a6f09 --- /dev/null +++ b/src/lib/growth/adjust-ingest.ts @@ -0,0 +1,110 @@ +/** + * Adjust S2S callback → ConversionEvent mapping (pure). + * + * Adjust's real-time callback is a URL template Adjust expands per-event and + * GETs/POSTs to us — see docs/growth/06-mmp-ingest.md §3. Only `install` and + * mapped `event` activity kinds are funnel-relevant; everything else + * (`reattribution`, `session`, unmapped event tokens) is dropped, not thrown, + * so a misconfigured callback never 500s the route. + * + * userKey (decision B, 06-mmp-ingest.md §2): prefer the RC `app_user_id` + * transmitted as an Adjust callback parameter — NOT a partner parameter, those go + * to ad networks; callback params come back on raw-data callbacks (AF analogue: + * customer_user_id/CUID). Set via SDK after registration. If absent, we + * fall back to `adjust:${adid}` — a namespaced id that intentionally does NOT + * join with GA4 pseudo ids or RC app_user_ids. Cohort-level consequences of + * that downgrade are documented in cohorts.ts / kpi-canon.ts. + * + * Ref: docs/growth/06-mmp-ingest.md §1 hole 3, §2 decision B, §3 + */ + +import { EVENTS, SOURCES, type ConversionEventInput, type EventName, type Os } from './events' +import { resolveAdjustChannel } from './channels' +import { parseCampaignName } from './campaign-name' + +/** Adjust `event_token` → our canonical EventName. Unmapped tokens are dropped. */ +export type AdjustEventTokenMap = Record + +export interface AdjustCallbackParams { + activity_kind?: string + event_token?: string + network_name?: string + campaign_name?: string + adid?: string + /** unix seconds, as Adjust sends it. */ + created_at?: string + country?: string + /** RC app_user_id transmitted as a callback parameter (decision B). */ + app_user_id?: string + /** Adjust standard placeholder — "ios" | "android" (native SDK installs). */ + os_name?: string + /** Adjust standard placeholder — used here only to catch web-SDK installs + * that report a device_type of "web" instead of an os_name. */ + device_type?: string + [key: string]: string | undefined +} + +/** Normalize Adjust's `os_name`/`device_type` placeholders to our Os enum. */ +function normalizeAdjustOs(params: AdjustCallbackParams): Os | null { + const osName = params.os_name?.trim().toLowerCase() + if (osName === 'ios') return 'ios' + if (osName === 'android') return 'android' + if (osName === 'web') return 'web' + const deviceType = params.device_type?.trim().toLowerCase() + if (deviceType === 'web') return 'web' + return null +} + +/** + * Map one Adjust S2S callback request to a normalized ConversionEventInput, or + * null if it isn't funnel-relevant (unmapped activity_kind / event_token, + * missing timestamp). + */ +export function mapAdjustCallback( + params: AdjustCallbackParams, + eventTokenMap: AdjustEventTokenMap = {}, +): ConversionEventInput | null { + const activityKind = params.activity_kind?.trim().toLowerCase() + + let eventName: EventName | undefined + if (activityKind === 'install') { + eventName = EVENTS.INSTALL + } else if (activityKind === 'event') { + const token = params.event_token + eventName = token ? eventTokenMap[token] : undefined + } + // 'reattribution', 'session', and anything else are not in our funnel + // vocabulary — drop, don't guess. + if (!eventName) return null + + const createdAtSec = Number(params.created_at) + if (!Number.isFinite(createdAtSec) || createdAtSec <= 0) return null + + const userKey = params.app_user_id?.trim() || (params.adid ? `adjust:${params.adid}` : null) + + // channel stays authoritative from network_name (resolveAdjustChannel) — + // the campaign-name parse below is only consulted for os fallback and the + // agency/bidStrategy/conversionGoal dimensions it uniquely owns (bi §7). + const { channel } = resolveAdjustChannel(params.network_name, params.campaign_name) + const parsedName = parseCampaignName(params.campaign_name) + + // os is authoritative from Adjust's own os_name/device_type fields; + // campaign-name-derived os is only a fallback when both are absent. + const os = normalizeAdjustOs(params) ?? parsedName?.os ?? null + + return { + source: SOURCES.ADJUST, + eventName, + occurredAt: new Date(createdAtSec * 1000), + userKey, + utmCampaign: params.campaign_name ?? null, + channel, + os, + country: params.country ?? null, + revenue: 0, + agency: parsedName?.agency ?? null, + bidStrategy: parsedName?.bidStrategy ?? null, + conversionGoal: parsedName?.goal ?? null, + raw: params, + } +} diff --git a/src/lib/growth/campaign-name.test.ts b/src/lib/growth/campaign-name.test.ts new file mode 100644 index 0000000..2030b3a --- /dev/null +++ b/src/lib/growth/campaign-name.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect } from 'vitest' +import { parseCampaignName } from './campaign-name' + +describe('parseCampaignName — full example', () => { + it('parses every positional field from the canon example', () => { + expect(parseCampaignName('inhouse-20260512-mai-Android-US/T1/JP-Google-01-Luddi-install-female-Davis-xx')).toEqual({ + agency: 'inhouse', + date: '20260512', + dateRaw: '20260512', + bidStrategy: 'mai', + os: 'android', + osRaw: 'Android', + regions: ['US', 'T1', 'JP'], + channelHint: 'Google', + index: '01', + product: 'Luddi', + goal: 'install', + audience: 'female', + custom: ['Davis', 'xx'], + }) + }) +}) + +describe('parseCampaignName — missing/short segments', () => { + it('fills what it can and leaves the rest null when segments run out', () => { + expect(parseCampaignName('inhouse-20260512')).toEqual({ + agency: 'inhouse', + date: '20260512', + dateRaw: '20260512', + bidStrategy: null, + os: null, + osRaw: null, + regions: [], + channelHint: null, + index: null, + product: null, + goal: null, + audience: null, + custom: [], + }) + }) + + it('handles exactly one dash (two segments)', () => { + const parsed = parseCampaignName('agencyname-20260101') + expect(parsed?.agency).toBe('agencyname') + expect(parsed?.date).toBe('20260101') + expect(parsed?.custom).toEqual([]) + }) +}) + +describe('parseCampaignName — extra segments', () => { + it('collects everything past position 10 into custom, in order', () => { + const parsed = parseCampaignName('a-20260101-b-ios-US-Meta-01-Prod-signup-male-extra1-extra2-extra3') + expect(parsed?.custom).toEqual(['extra1', 'extra2', 'extra3']) + }) +}) + +describe('parseCampaignName — regions', () => { + it('splits multiple region codes on /', () => { + const parsed = parseCampaignName('a-20260101-b-ios-US/CA/UK-Meta') + expect(parsed?.regions).toEqual(['US', 'CA', 'UK']) + }) + + it('single region', () => { + const parsed = parseCampaignName('a-20260101-b-ios-JP-Meta') + expect(parsed?.regions).toEqual(['JP']) + }) + + it('missing region segment yields empty array', () => { + const parsed = parseCampaignName('a-20260101-b-ios') + expect(parsed?.regions).toEqual([]) + }) +}) + +describe('parseCampaignName — os normalization', () => { + it('is case-insensitive for known os values', () => { + expect(parseCampaignName('a-20260101-b-IOS')?.os).toBe('ios') + expect(parseCampaignName('a-20260101-b-ANDROID')?.os).toBe('android') + expect(parseCampaignName('a-20260101-b-Web')?.os).toBe('web') + }) + + it('unrecognized os text is null but preserved in osRaw', () => { + const parsed = parseCampaignName('a-20260101-b-PlayStation') + expect(parsed?.os).toBeNull() + expect(parsed?.osRaw).toBe('PlayStation') + }) + + it('missing os segment is null/null', () => { + const parsed = parseCampaignName('a-20260101') + expect(parsed?.os).toBeNull() + expect(parsed?.osRaw).toBeNull() + }) +}) + +describe('parseCampaignName — date validation', () => { + it('rejects a non-8-digit date but preserves the raw text', () => { + const parsed = parseCampaignName('a-2026-05-12-b') + // "2026" is only 4 digits — invalid, but this also demonstrates that + // the date segment is strictly the 2nd `-`-delimited segment; extra + // dashes inside what a human might call "the date" just become more + // segments, which is expected positional behavior. + expect(parsed?.date).toBeNull() + expect(parsed?.dateRaw).toBe('2026') + }) + + it('rejects non-numeric date text', () => { + const parsed = parseCampaignName('a-notadate-b') + expect(parsed?.date).toBeNull() + expect(parsed?.dateRaw).toBe('notadate') + }) + + it('accepts a valid 8-digit YYYYMMDD-shaped date (no calendar validation)', () => { + expect(parseCampaignName('a-20260230-b')?.date).toBe('20260230') + }) +}) + +describe('parseCampaignName — agency/bidStrategy/goal lowercasing, no whitelist', () => { + it('lowercases agency, bidStrategy, and goal but keeps any custom word', () => { + const parsed = parseCampaignName('InHouse-20260101-MAI-ios-US-Meta-01-Prod-INSTALL-male') + expect(parsed?.agency).toBe('inhouse') + expect(parsed?.bidStrategy).toBe('mai') + expect(parsed?.goal).toBe('install') + }) + + it('accepts any agency/bidStrategy/goal word — no enum validation', () => { + const parsed = parseCampaignName('totallyMadeUpAgency-20260101-madeUpBidStrategy-ios-US-Meta-01-Prod-madeUpGoal-male') + expect(parsed?.agency).toBe('totallymadeupagency') + expect(parsed?.bidStrategy).toBe('madeupbidstrategy') + expect(parsed?.goal).toBe('madeupgoal') + }) + + it('keeps channelHint, product, audience, and custom verbatim (no lowercasing)', () => { + const parsed = parseCampaignName('a-20260101-b-ios-US-Google-01-Luddi-install-Female-CustomTag') + expect(parsed?.channelHint).toBe('Google') + expect(parsed?.product).toBe('Luddi') + expect(parsed?.audience).toBe('Female') + expect(parsed?.custom).toEqual(['CustomTag']) + }) +}) + +describe('parseCampaignName — malformed input never throws', () => { + it('empty string → null', () => { + expect(parseCampaignName('')).toBeNull() + }) + + it('whitespace-only string → null', () => { + expect(parseCampaignName(' ')).toBeNull() + }) + + it('no dash at all → null', () => { + expect(parseCampaignName('justonesegment')).toBeNull() + }) + + it('non-string inputs → null', () => { + expect(parseCampaignName(null)).toBeNull() + expect(parseCampaignName(undefined)).toBeNull() + expect(parseCampaignName(123)).toBeNull() + expect(parseCampaignName({})).toBeNull() + expect(parseCampaignName(['a', 'b'])).toBeNull() + }) + + it('leading/trailing dashes still parse without throwing', () => { + expect(() => parseCampaignName('-a-b-')).not.toThrow() + const parsed = parseCampaignName('-a-b-') + // leading '-' produces an empty first segment → agency null + expect(parsed?.agency).toBeNull() + }) + + it('consecutive dashes (empty segment) do not throw and read as null', () => { + expect(() => parseCampaignName('a--b')).not.toThrow() + const parsed = parseCampaignName('a--b') + expect(parsed?.agency).toBe('a') + expect(parsed?.date).toBeNull() + expect(parsed?.dateRaw).toBeNull() + }) +}) diff --git a/src/lib/growth/campaign-name.ts b/src/lib/growth/campaign-name.ts new file mode 100644 index 0000000..d6cd20c --- /dev/null +++ b/src/lib/growth/campaign-name.ts @@ -0,0 +1,113 @@ +/** + * Campaign-name canon (docs/growth/06-mmp-ingest.md §7) — the positional + * naming convention marketers use for every campaign, and the pure parser + * that turns a raw campaign name into structured dimensions. + * + * Convention (`-`-delimited, position = semantics): + * agency-date-bidStrategy-os-region(s)-channelHint-index-product-goal-audience-custom... + * Example: + * inhouse-20260512-mai-Android-US/T1/JP-Google-01-Luddi-install-female-Davis-xx + * + * Design notes: + * - Word lists (agency / bidStrategy / conversionGoal) are customer-defined, + * not a fixed enum — we don't whitelist-validate them, just lowercase them + * for consistent grouping/joins. Position-carrying but free-text fields + * (channelHint, product, audience, custom) are kept verbatim. + * - Segment count can be short (fields past the last segment are `null`) or + * long (anything past position 10 collects into `custom`). + * - `os` is enum-normalized (ios | android | web); unrecognized text is kept + * in `osRaw` rather than guessed. Same pattern for `date`/`dateRaw`. + * - Never throws. Malformed input (empty string, no `-` at all, non-string) + * returns `null` — the caller (adjust-ingest.ts, report-writer.ts) treats + * that as "can't attribute from this name", not an error. + * + * Ref: docs/growth/06-mmp-ingest.md §7 + */ + +export type CampaignOs = 'ios' | 'android' | 'web' + +export interface ParsedCampaignName { + /** Media-buying agency, lowercased. Null if the segment is missing/empty. */ + agency: string | null + /** Campaign start/launch date, validated `YYYYMMDD`. Null if missing or malformed. */ + date: string | null + /** The raw date segment as written, regardless of whether it validated. */ + dateRaw: string | null + /** Bid strategy label, lowercased. Null if missing. */ + bidStrategy: string | null + /** Enum-normalized OS, or null if the segment is missing/unrecognized. */ + os: CampaignOs | null + /** The raw os segment as written. */ + osRaw: string | null + /** Region codes, split on `/` within the region segment (e.g. "US/T1/JP" → ["US","T1","JP"]). */ + regions: string[] + /** Free-text channel hint (e.g. "Google") — verbatim, not resolved to a canonical Channel. */ + channelHint: string | null + /** Campaign index/serial (e.g. "01"), verbatim. */ + index: string | null + /** Product name, verbatim. */ + product: string | null + /** Conversion goal, lowercased. */ + goal: string | null + /** Audience/targeting hint, verbatim. */ + audience: string | null + /** Any segments beyond position 10, verbatim, in order. */ + custom: string[] +} + +const DATE_RE = /^\d{8}$/ + +function normalizeOs(raw: string | null): CampaignOs | null { + if (!raw) return null + const v = raw.trim().toLowerCase() + if (v === 'ios' || v === 'android' || v === 'web') return v + return null +} + +/** `undefined`/empty-string segment → null; otherwise the segment as-is. */ +function seg(segments: string[], i: number): string | null { + const v = segments[i] + return v !== undefined && v !== '' ? v : null +} + +/** + * Parse a campaign name into its positional dimensions, or `null` if the + * input isn't a non-empty string containing at least one `-` separator. + * Pure, never throws. + */ +export function parseCampaignName(name: unknown): ParsedCampaignName | null { + if (typeof name !== 'string') return null + const trimmed = name.trim() + if (!trimmed) return null + + const segments = trimmed.split('-') + if (segments.length < 2) return null + + const agencyRaw = seg(segments, 0) + const dateRaw = seg(segments, 1) + const bidStrategyRaw = seg(segments, 2) + const osRaw = seg(segments, 3) + const regionRaw = seg(segments, 4) + const channelHint = seg(segments, 5) + const index = seg(segments, 6) + const product = seg(segments, 7) + const goalRaw = seg(segments, 8) + const audience = seg(segments, 9) + const custom = segments.slice(10) + + return { + agency: agencyRaw ? agencyRaw.toLowerCase() : null, + date: dateRaw && DATE_RE.test(dateRaw) ? dateRaw : null, + dateRaw, + bidStrategy: bidStrategyRaw ? bidStrategyRaw.toLowerCase() : null, + os: normalizeOs(osRaw), + osRaw, + regions: regionRaw ? regionRaw.split('/').filter(Boolean) : [], + channelHint, + index, + product, + goal: goalRaw ? goalRaw.toLowerCase() : null, + audience, + custom, + } +} diff --git a/src/lib/growth/channels.test.ts b/src/lib/growth/channels.test.ts index e756ee7..67ce90f 100644 --- a/src/lib/growth/channels.test.ts +++ b/src/lib/growth/channels.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { CHANNELS, isChannel, isPaidChannel, isSkanChannel, resolveChannel } from './channels' +import { CHANNELS, isChannel, isPaidChannel, isSkanChannel, resolveChannel, resolveAdjustChannel, channelToPlatform } from './channels' describe('channel classification', () => { it('paid channels are flagged, earned are not', () => { @@ -59,3 +59,65 @@ describe('resolveChannel — UTM convention adex_{arm}', () => { expect(resolveChannel({ utmSource: 'mystery' })).toEqual({ channel: CHANNELS.ORGANIC, confidence: 'inferred' }) }) }) + +describe('resolveAdjustChannel — MMP network_name mapping', () => { + it('maps Apple Search Ads deterministically', () => { + expect(resolveAdjustChannel('Apple Search Ads')).toEqual({ + channel: CHANNELS.PAID_ASA, + confidence: 'deterministic', + }) + }) + it('maps Meta/TikTok install networks to the iOS SKAN channel (conservative app-install default)', () => { + expect(resolveAdjustChannel('Facebook Installs')).toEqual({ + channel: CHANNELS.PAID_META_IOS, + confidence: 'skan', + }) + expect(resolveAdjustChannel('Instagram Installs').channel).toBe(CHANNELS.PAID_META_IOS) + expect(resolveAdjustChannel('TikTok Installs')).toEqual({ + channel: CHANNELS.PAID_TIKTOK_IOS, + confidence: 'skan', + }) + }) + it('is case-insensitive on network name', () => { + expect(resolveAdjustChannel('APPLE SEARCH ADS').channel).toBe(CHANNELS.PAID_ASA) + }) + it('prefers a web-funnel hint in campaign_name over the app-install default', () => { + expect(resolveAdjustChannel('Facebook Installs', 'Q3 Web Retargeting')).toEqual({ + channel: CHANNELS.PAID_META_WEB, + confidence: 'inferred', + }) + expect(resolveAdjustChannel('TikTok Installs', 'web-lander-us').channel).toBe(CHANNELS.PAID_TIKTOK_WEB) + }) + it('unmapped network names fall back to organic/inferred, never a paid channel', () => { + expect(resolveAdjustChannel('Some Unknown Network')).toEqual({ + channel: CHANNELS.ORGANIC, + confidence: 'inferred', + }) + expect(resolveAdjustChannel(null)).toEqual({ channel: CHANNELS.ORGANIC, confidence: 'inferred' }) + }) + it('explicit organic network is deterministic', () => { + expect(resolveAdjustChannel('organic')).toEqual({ channel: CHANNELS.ORGANIC, confidence: 'deterministic' }) + }) +}) + +describe('channelToPlatform — bi §7 funnel↔spend join key', () => { + it('maps paid channels to their ad-platform string', () => { + expect(channelToPlatform(CHANNELS.PAID_GOOGLE_UAC)).toBe('google') + expect(channelToPlatform(CHANNELS.PAID_META_WEB)).toBe('meta') + expect(channelToPlatform(CHANNELS.PAID_META_IOS)).toBe('meta') + expect(channelToPlatform(CHANNELS.PAID_TIKTOK_WEB)).toBe('tiktok') + expect(channelToPlatform(CHANNELS.PAID_TIKTOK_IOS)).toBe('tiktok') + expect(channelToPlatform(CHANNELS.PAID_ASA)).toBe('apple_search_ads') + }) + it('earned/organic channels have no ad platform — null, not guessed', () => { + expect(channelToPlatform(CHANNELS.ORGANIC)).toBeNull() + expect(channelToPlatform(CHANNELS.KOL)).toBeNull() + expect(channelToPlatform(CHANNELS.REFERRAL)).toBeNull() + expect(channelToPlatform(CHANNELS.SEO)).toBeNull() + expect(channelToPlatform(CHANNELS.ASO)).toBeNull() + }) + it('unrecognized channel strings are null, never throw', () => { + expect(channelToPlatform('not_a_channel')).toBeNull() + expect(channelToPlatform('')).toBeNull() + }) +}) diff --git a/src/lib/growth/channels.ts b/src/lib/growth/channels.ts index 5acaef6..4af3448 100644 --- a/src/lib/growth/channels.ts +++ b/src/lib/growth/channels.ts @@ -101,3 +101,92 @@ export function resolveChannel(input: { return { channel: CHANNELS.ORGANIC, confidence: src ? 'inferred' : 'deterministic' } } + +// ── Adjust (MMP) network → channel mapping ────────────────────────────── +// Adjust callbacks report `network_name` (and `campaign_name`), not our +// adex_{arm} UTM convention — resolveChannel() doesn't recognize these names +// and would bucket them all into organic (the "channel norm失配" hole, +// docs/growth/06-mmp-ingest.md §1 hole 2). This is a separate, explicit table +// so resolveChannel's own UTM-first contract stays untouched. +// +// Adjust's `network_name` doesn't distinguish web vs iOS-app-install for +// Meta/TikTok — both surface as e.g. "Facebook Installs" / "Instagram +// Installs". We conservatively map these to the *_ios (SKAN) channel, the +// lower-trust/lower-confidence bucket, since Adjust is an app-install MMP and +// most orgs wiring it up are attributing native app installs, not the web +// funnel. If `campaignName` carries a recognizable hint (e.g. contains "web"), +// we prefer that signal over the network-name default. This is a documented +// approximation — revisit if a customer's actual Adjust setup differs. +export const ADJUST_NETWORK_MAP: Record = { + 'apple search ads': CHANNELS.PAID_ASA, + 'facebook installs': CHANNELS.PAID_META_IOS, + 'facebook ads': CHANNELS.PAID_META_IOS, + 'instagram installs': CHANNELS.PAID_META_IOS, + 'meta installs': CHANNELS.PAID_META_IOS, + 'tiktok installs': CHANNELS.PAID_TIKTOK_IOS, + 'tiktok for business': CHANNELS.PAID_TIKTOK_IOS, + 'google ads': CHANNELS.PAID_GOOGLE_UAC, + 'google installs': CHANNELS.PAID_GOOGLE_UAC, + organic: CHANNELS.ORGANIC, +} + +/** + * Resolve an Adjust `network_name` (+ optional `campaign_name`) to a canonical + * channel. Case-insensitive on network name. Unmapped networks fall back to + * organic with `inferred` confidence (never throws, never silently mis-buckets + * as a paid channel). + */ +export function resolveAdjustChannel( + networkName?: string | null, + campaignName?: string | null, +): { channel: Channel; confidence: Confidence } { + const network = (networkName ?? '').trim().toLowerCase() + const campaign = (campaignName ?? '').trim().toLowerCase() + + const mapped = ADJUST_NETWORK_MAP[network] + if (!mapped) { + return { channel: CHANNELS.ORGANIC, confidence: 'inferred' } + } + + // Meta/TikTok network names are app-install-only in our table (mapped to the + // *_ios SKAN channel); if the campaign name explicitly signals a web-funnel + // campaign, prefer that over the app-install default. + if (mapped === CHANNELS.PAID_META_IOS && campaign.includes('web')) { + return { channel: CHANNELS.PAID_META_WEB, confidence: 'inferred' } + } + if (mapped === CHANNELS.PAID_TIKTOK_IOS && campaign.includes('web')) { + return { channel: CHANNELS.PAID_TIKTOK_WEB, confidence: 'inferred' } + } + + if (mapped === CHANNELS.PAID_ASA) return { channel: mapped, confidence: 'deterministic' } + if (isSkanChannel(mapped)) return { channel: mapped, confidence: 'skan' } + if (mapped === CHANNELS.ORGANIC) return { channel: mapped, confidence: 'deterministic' } + return { channel: mapped, confidence: 'inferred' } +} + +// ── CohortSnapshot.channel → Report.platform bridge (bi §7) ──────────────── +// Report rows carry a bare ad-platform string (google/meta/tiktok/...), not a +// Channel — this is the inverse direction of PLATFORM_TO_CHANNEL +// (src/app/api/cron/growth-sync/route.ts), used by /api/reports/breakdown to +// join spend rows back to CohortSnapshot funnel rows on (date, os, platform, +// agency). Earned channels (organic/kol/referral/seo/aso) have no +// corresponding ad platform and correctly map to null — they never join. +const CHANNEL_TO_PLATFORM: Partial> = { + [CHANNELS.PAID_GOOGLE_UAC]: 'google', + [CHANNELS.PAID_META_WEB]: 'meta', + [CHANNELS.PAID_META_IOS]: 'meta', + [CHANNELS.PAID_TIKTOK_WEB]: 'tiktok', + [CHANNELS.PAID_TIKTOK_IOS]: 'tiktok', + [CHANNELS.PAID_ASA]: 'apple_search_ads', +} + +/** + * Resolve a CohortSnapshot channel to the Report.platform string it can join + * against. Earned/organic channels and any channel this map doesn't cover + * return `null` — they don't participate in the funnel↔spend join, they're + * not "joined to zero". + */ +export function channelToPlatform(channel: string): string | null { + if (!isChannel(channel)) return null + return CHANNEL_TO_PLATFORM[channel] ?? null +} diff --git a/src/lib/growth/cohorts.test.ts b/src/lib/growth/cohorts.test.ts index 7ab8f8a..ce3791a 100644 --- a/src/lib/growth/cohorts.test.ts +++ b/src/lib/growth/cohorts.test.ts @@ -6,7 +6,7 @@ import { CHANNELS } from './channels' const D = (iso: string) => new Date(iso) function ev(p: Partial & Pick): RawEvent { - return { userKey: 'u1', channel: null, revenue: 0, ...p } + return { userKey: 'u1', channel: null, os: null, revenue: 0, source: 'ga4', ...p } } describe('dayKey', () => { @@ -84,4 +84,110 @@ describe('buildCohortSnapshots', () => { expect(rows[0].cac).toBeCloseTo(10) // $20 / 2 installs expect(buildCohortSnapshots(events)[0].cac).toBeNull() }) + + it('single install-source authority: same real install reported by GA4 + Adjust under different userKey namespaces does not double-count (decision A)', () => { + const events: RawEvent[] = [ + // Same physical device/user, but GA4 and Adjust can't be joined (decision B) — + // they land as two distinct userKeys. + ev({ + userKey: 'ga4-pseudo-1', + eventName: EVENTS.INSTALL, + occurredAt: D('2026-07-01T08:00:00Z'), + channel: CHANNELS.ORGANIC, + source: 'ga4', + }), + ev({ + userKey: 'adjust:abc123', + eventName: EVENTS.INSTALL, + occurredAt: D('2026-07-01T08:05:00Z'), + channel: CHANNELS.PAID_ASA, + source: 'adjust', + }), + ] + // Without an authority, both userKeys place a cohort row each — the hole. + expect(buildCohortSnapshots(events).reduce((s, r) => s + r.installs, 0)).toBe(2) + + // With Adjust as install authority, the GA4 install is excluded from + // acquisition placement (that userKey has no other acquisition event, so + // it drops out entirely) — only the Adjust-attributed install counts. + const rows = buildCohortSnapshots(events, { installAuthority: 'adjust' }) + expect(rows).toHaveLength(1) + expect(rows[0]).toMatchObject({ installs: 1, channel: CHANNELS.PAID_ASA }) + }) + + it('bi §6: signup anchors the cohort even when installAuthority would have excluded it as install-source noise', () => { + // A GA4 signup — installAuthority='adjust' would exclude a GA4 INSTALL, + // but signup is never authority-filtered. + const rows = buildCohortSnapshots( + [ev({ userKey: 'a', eventName: EVENTS.SIGNUP, occurredAt: D('2026-07-01T10:00:00Z'), source: 'ga4', channel: CHANNELS.ORGANIC })], + { installAuthority: 'adjust' }, + ) + expect(rows).toHaveLength(1) + expect(rows[0]).toMatchObject({ cohortDate: '2026-07-01', signups: 1, installs: 0 }) + }) + + it('bi §6: install-based MMP attribution overrides the signup event\'s own channel', () => { + const rows = buildCohortSnapshots([ + ev({ userKey: 'a', eventName: EVENTS.INSTALL, occurredAt: D('2026-07-01T08:00:00Z'), channel: CHANNELS.PAID_ASA, source: 'adjust' }), + ev({ userKey: 'a', eventName: EVENTS.SIGNUP, occurredAt: D('2026-07-01T10:00:00Z'), channel: CHANNELS.ORGANIC, source: 'ga4' }), + ]) + expect(rows).toHaveLength(1) + // Anchored on the signup day, but channel comes from the install. + expect(rows[0]).toMatchObject({ cohortDate: '2026-07-01', channel: CHANNELS.PAID_ASA, signups: 1, installs: 0 }) + }) + + it('bi §6: a pure web signup with no install event falls back to its own channel/os', () => { + const rows = buildCohortSnapshots([ + ev({ userKey: 'a', eventName: EVENTS.SIGNUP, occurredAt: D('2026-07-01T10:00:00Z'), channel: CHANNELS.PAID_META_WEB, os: 'web' }), + ]) + expect(rows).toHaveLength(1) + expect(rows[0]).toMatchObject({ channel: CHANNELS.PAID_META_WEB, os: 'web', signups: 1, installs: 0 }) + }) + + it('bi §6: os groups separately even for the same date/channel', () => { + const rows = buildCohortSnapshots([ + ev({ userKey: 'a', eventName: EVENTS.INSTALL, occurredAt: D('2026-07-01T08:00:00Z'), channel: CHANNELS.PAID_ASA, os: 'ios' }), + ev({ userKey: 'b', eventName: EVENTS.INSTALL, occurredAt: D('2026-07-01T09:00:00Z'), channel: CHANNELS.PAID_ASA, os: 'android' }), + ]) + expect(rows).toHaveLength(2) + expect(rows.map((r) => r.os).sort()).toEqual(['android', 'ios']) + }) + + it('bi §6: revenueD0/revenueD7 window boundaries around the cohort anchor day', () => { + const rows = buildCohortSnapshots([ + ev({ userKey: 'a', eventName: EVENTS.INSTALL, occurredAt: D('2026-07-01T08:00:00Z'), channel: CHANNELS.PAID_ASA }), + // D0: same calendar day as the anchor. + ev({ userKey: 'a', eventName: EVENTS.SUBSCRIPTION_ACTIVATED, occurredAt: D('2026-07-01T20:00:00Z'), revenue: 5 }), + // Within the D7 window (cohortDate + 7) but after D0. + ev({ userKey: 'a', eventName: EVENTS.SUBSCRIPTION_ACTIVATED, occurredAt: D('2026-07-08T00:00:00Z'), revenue: 3 }), + // Outside the D7 window (cohortDate + 8). + ev({ userKey: 'a', eventName: EVENTS.SUBSCRIPTION_ACTIVATED, occurredAt: D('2026-07-09T00:00:00Z'), revenue: 7 }), + ]) + expect(rows[0].revenueD0).toBeCloseTo(5) + expect(rows[0].revenueD7).toBeCloseTo(8) // 5 + 3, not the 7 on day+8 + expect(rows[0].revenueToDate).toBeCloseTo(15) // unbounded total includes all three + }) + + it('installAuthority never filters funnel-deep/revenue events, only acquisition events', () => { + const rows = buildCohortSnapshots( + [ + ev({ + userKey: 'adjust:abc123', + eventName: EVENTS.INSTALL, + occurredAt: D('2026-07-01T08:00:00Z'), + channel: CHANNELS.PAID_ASA, + source: 'adjust', + }), + // Adjust doesn't report first_chat — it arrives from GA4, must still count. + ev({ + userKey: 'adjust:abc123', + eventName: EVENTS.FIRST_CHAT, + occurredAt: D('2026-07-01T09:00:00Z'), + source: 'ga4', + }), + ], + { installAuthority: 'adjust' }, + ) + expect(rows[0]).toMatchObject({ installs: 1, activated: 1 }) + }) }) diff --git a/src/lib/growth/cohorts.ts b/src/lib/growth/cohorts.ts index bc4adb0..72e25b7 100644 --- a/src/lib/growth/cohorts.ts +++ b/src/lib/growth/cohorts.ts @@ -1,20 +1,53 @@ /** * Cohort aggregation (pure) — folds normalized ConversionEvents into - * per-acquisition-day × channel CohortSnapshot rows. The growth-sync cron runs - * this over a rolling window and persists the output. + * per-acquisition-day × channel × os CohortSnapshot rows. The growth-sync cron + * runs this over a rolling window and persists the output. * - * A user's cohort = the calendar day and channel of their FIRST acquisition - * event (install, else signup). Their later events (first_chat=activation, - * engagement on day+1/+7=retention, trial, subscription, revenue) roll up to - * that cohort. Revenue events from RevenueCat carry no channel of their own — - * they attach to the user's acquisition channel via userKey. Events with no - * userKey cannot be placed in a cohort and are ignored here. + * Acquisition anchor (bi §6, docs/growth/06-mmp-ingest.md §6): a user's cohort + * = the calendar day of their FIRST signup event, from ANY source — signup is + * never filtered by installAuthority, because it's our own first-party event, + * not a duplicated MMP/GA4 install signal. A user with no signup falls back to + * their first ELIGIBLE install (install candidates ARE still filtered by + * installAuthority, same as before — that's the single-install-source- + * authority fix for double-counted installs, decision A). A user with neither + * a signup nor an eligible install cannot be placed in a cohort and is + * ignored. + * + * Channel/os/agency attribution: once a user is anchored, their channel, os, + * and agency are NOT necessarily read off the anchor event. Install-based MMP + * attribution (Adjust/AppsFlyer network + device signal + campaign-name parse, + * docs/growth/06-mmp-ingest.md §7) is more reliable than a signup's + * self-reported UTM, so if the user has ANY eligible install event (same + * installAuthority filter), its channel/os/agency wins even when the anchor + * itself is the signup. Only signup-anchored users with no install event at + * all fall back to the signup's own channel/os/agency (e.g. a pure web signup + * with no MMP involved). + * + * Revenue events from RevenueCat carry no channel of their own — they attach + * to the user's acquisition channel via userKey. Events with no userKey + * cannot be placed in a cohort and are ignored here. * * Retention proxy: presence of an engagement event (first_chat / scene_generated) * on cohortDay+N. Documented as a proxy — a dedicated GA4 retention signal can * refine it later without changing this contract. * - * Ref: docs/growth/00-cuddler-first-redesign.md §4.1 · kpi-canon.realizedLtv + * revenueD0 / revenueD7 (bi §6): windows of `subscription_activated` revenue + * only (not renewals) relative to the cohort anchor day — D0 = dayDiff ≤ 0 + * (the anchor day itself; revenue can't legitimately precede acquisition, but + * the ≤0 bound is the documented, literal spec), D7 = dayDiff ≤ 7 (anchor day + * through +7 inclusive). These are subsets of `revenueToDate`, which still + * sums ALL revenue events (including renewals) with no time bound. + * + * Single install-source authority (decision A, docs/growth/06-mmp-ingest.md §2): + * when an org has both GA4 and Adjust wired, `opts.installAuthority` restricts + * which source's INSTALL events are eligible acquisition/attribution + * candidates — otherwise the same real install lands twice (once per source, + * under two different userKey namespaces) and installs double-count. Signup, + * funnel-deep (first_chat/scene_generated), and revenue events are never + * filtered by source — Adjust doesn't report those at all, so filtering them + * would zero out activation/retention for MMP-attributed cohorts. + * + * Ref: docs/growth/00-cuddler-first-redesign.md §4.1 · docs/growth/06-mmp-ingest.md §6 · kpi-canon.realizedLtv */ import { EVENTS, type EventName } from './events' @@ -26,24 +59,35 @@ export interface RawEvent { occurredAt: Date userKey: string | null channel: string | null + /** ios | android | web, when known. */ + os?: string | null + /** Campaign-name-derived agency (bi §7), when known. */ + agency?: string | null revenue: number + /** ConversionEvent.source (ga4 | revenuecat | deeplink | adjust | backend). */ + source: string } export interface CohortRow { cohortDate: string // YYYY-MM-DD (UTC) channel: string + os: string | null + /** Campaign-name-derived agency (bi §7), when known. */ + agency: string | null installs: number + signups: number activated: number d1Retained: number d7Retained: number trials: number subscribers: number revenueToDate: number + revenueD0: number + revenueD7: number ltvEstimate: number cac: number | null } -const ACQUISITION: Set = new Set([EVENTS.INSTALL, EVENTS.SIGNUP]) const ENGAGEMENT: Set = new Set([EVENTS.FIRST_CHAT, EVENTS.SCENE_GENERATED]) /** UTC calendar day key. */ @@ -51,20 +95,25 @@ export function dayKey(d: Date): string { return d.toISOString().slice(0, 10) } -/** Whole-day difference (UTC) between two day keys. */ +/** Whole-day difference (UTC) between a cohort day key and an event date. */ function dayDiff(fromKey: string, to: Date): number { const from = Date.parse(fromKey + 'T00:00:00.000Z') const toDay = Date.parse(dayKey(to) + 'T00:00:00.000Z') return Math.round((toDay - from) / 86_400_000) } +const byTime = (a: RawEvent, b: RawEvent) => a.occurredAt.getTime() - b.occurredAt.getTime() + /** * Build cohort rows from a user's full event set. `spendByCohort` optionally - * supplies media spend keyed by `${cohortDate}|${channel}` to compute CAC. + * supplies media spend keyed by `${cohortDate}|${channel}` (channel-only, not + * os-qualified — os has no independent spend line yet) to compute CAC. + * `installAuthority`, if set, restricts which source's INSTALL events are + * eligible acquisition/attribution candidates — see the module doc comment. */ export function buildCohortSnapshots( events: RawEvent[], - opts: { spendByCohort?: Map } = {}, + opts: { spendByCohort?: Map; installAuthority?: string } = {}, ): CohortRow[] { // 1. Group events by user. const byUser = new Map() @@ -76,47 +125,89 @@ export function buildCohortSnapshots( } // 2. Reduce each user to a cohort contribution. - type Acc = Omit + type Acc = Omit const cohorts = new Map() - const keyOf = (cohortDate: string, channel: string) => `${cohortDate}|${channel}` + const keyOf = (cohortDate: string, channel: string, os: string | null, agency: string | null) => + `${cohortDate}|${channel}|${os ?? ''}|${agency ?? ''}` for (const userEvents of byUser.values()) { - const acquisition = userEvents - .filter((e) => ACQUISITION.has(e.eventName)) - .sort((a, b) => a.occurredAt.getTime() - b.occurredAt.getTime())[0] - if (!acquisition) continue // no install/signup → can't place in a cohort - - const cohortDate = dayKey(acquisition.occurredAt) - const channel = acquisition.channel || CHANNELS.ORGANIC - const key = keyOf(cohortDate, channel) + const signupEvent = userEvents + .filter((e) => e.eventName === EVENTS.SIGNUP) + .sort(byTime)[0] + const installEvent = userEvents + .filter((e) => e.eventName === EVENTS.INSTALL && (!opts.installAuthority || e.source === opts.installAuthority)) + .sort(byTime)[0] + + const anchoredBySignup = !!signupEvent + const anchor = signupEvent ?? installEvent + if (!anchor) continue // no signup, no eligible install → can't place in a cohort + + const cohortDate = dayKey(anchor.occurredAt) + // MMP install attribution beats a signup's self-reported UTM, whether or + // not the install is the anchor itself. + const attribution = installEvent ?? anchor + const channel = attribution.channel || CHANNELS.ORGANIC + const os = attribution.os ?? null + const agency = attribution.agency ?? null + + const key = keyOf(cohortDate, channel, os, agency) let acc = cohorts.get(key) if (!acc) { - acc = { installs: 0, activated: 0, d1Retained: 0, d7Retained: 0, trials: 0, subscribers: 0, revenueToDate: 0 } + acc = { + installs: 0, + signups: 0, + activated: 0, + d1Retained: 0, + d7Retained: 0, + trials: 0, + subscribers: 0, + revenueToDate: 0, + revenueD0: 0, + revenueD7: 0, + } cohorts.set(key, acc) } - acc.installs += 1 + if (anchoredBySignup) acc.signups += 1 + else acc.installs += 1 + if (userEvents.some((e) => e.eventName === EVENTS.FIRST_CHAT)) acc.activated += 1 if (userEvents.some((e) => ENGAGEMENT.has(e.eventName) && dayDiff(cohortDate, e.occurredAt) === 1)) acc.d1Retained += 1 if (userEvents.some((e) => ENGAGEMENT.has(e.eventName) && dayDiff(cohortDate, e.occurredAt) === 7)) acc.d7Retained += 1 if (userEvents.some((e) => e.eventName === EVENTS.TRIAL_START)) acc.trials += 1 if (userEvents.some((e) => e.eventName === EVENTS.SUBSCRIPTION_ACTIVATED)) acc.subscribers += 1 acc.revenueToDate += userEvents.reduce((s, e) => s + (e.revenue || 0), 0) + + for (const e of userEvents) { + if (e.eventName !== EVENTS.SUBSCRIPTION_ACTIVATED) continue + const diff = dayDiff(cohortDate, e.occurredAt) + if (diff <= 0) acc.revenueD0 += e.revenue || 0 + if (diff <= 7) acc.revenueD7 += e.revenue || 0 + } } // 3. Finalize: attach LTV + CAC. const rows: CohortRow[] = [] for (const [key, acc] of cohorts) { - const [cohortDate, channel] = key.split('|') - const spend = opts.spendByCohort?.get(key) + const [cohortDate, channel, osKey, agencyKey] = key.split('|') + const os = osKey === '' ? null : osKey + const agency = agencyKey === '' ? null : agencyKey + const cohortSize = acc.installs + acc.signups + const spend = opts.spendByCohort?.get(`${cohortDate}|${channel}`) rows.push({ cohortDate, channel, + os, + agency, ...acc, - ltvEstimate: realizedLtv(acc.revenueToDate, acc.installs), - cac: spend !== undefined && acc.installs > 0 ? spend / acc.installs : null, + ltvEstimate: realizedLtv(acc.revenueToDate, cohortSize), + cac: spend !== undefined && cohortSize > 0 ? spend / cohortSize : null, }) } - rows.sort((a, b) => (a.cohortDate === b.cohortDate ? a.channel.localeCompare(b.channel) : a.cohortDate.localeCompare(b.cohortDate))) + rows.sort((a, b) => { + if (a.cohortDate !== b.cohortDate) return a.cohortDate.localeCompare(b.cohortDate) + if (a.channel !== b.channel) return a.channel.localeCompare(b.channel) + return (a.os ?? '').localeCompare(b.os ?? '') + }) return rows } diff --git a/src/lib/growth/events.ts b/src/lib/growth/events.ts index fc05403..ebfc793 100644 --- a/src/lib/growth/events.ts +++ b/src/lib/growth/events.ts @@ -28,10 +28,21 @@ export const SOURCES = { REVENUECAT: 'revenuecat', DEEPLINK: 'deeplink', ADJUST: 'adjust', + // Server-originated events (e.g. our own backend emitting canonical events + // directly, bypassing GA4/MMP) — bi §6. + BACKEND: 'backend', } as const export type EventSource = (typeof SOURCES)[keyof typeof SOURCES] +/** Canonical OS buckets (ConversionEvent.os / CohortSnapshot.os / Report.os). */ +export const OS_VALUES = ['ios', 'android', 'web'] as const +export type Os = (typeof OS_VALUES)[number] + +export function isOs(v: unknown): v is Os { + return typeof v === 'string' && (OS_VALUES as readonly string[]).includes(v) +} + const REVENUE_EVENTS: Set = new Set([ EVENTS.SUBSCRIPTION_ACTIVATED, EVENTS.RENEWAL, @@ -55,7 +66,12 @@ export interface ConversionEventInput { utmSource?: string | null utmCampaign?: string | null channel?: Channel | null + os?: Os | null country?: string | null revenue?: number + /** Campaign-name-derived dimensions (docs/growth/06-mmp-ingest.md §7). */ + agency?: string | null + bidStrategy?: string | null + conversionGoal?: string | null raw?: unknown } diff --git a/src/lib/growth/ingest-parse.test.ts b/src/lib/growth/ingest-parse.test.ts index 23c943f..31c5c07 100644 --- a/src/lib/growth/ingest-parse.test.ts +++ b/src/lib/growth/ingest-parse.test.ts @@ -44,3 +44,18 @@ describe('parseIncomingEvents', () => { expect(parseIncomingEvents([{ source: 'ga4', eventName: 'install', occurredAt: at }])).toHaveLength(1) }) }) + +describe('parseIncomingEvent — os (bi §6)', () => { + it('accepts an explicit valid os value', () => { + const r = parseIncomingEvent({ source: 'ga4', eventName: 'install', occurredAt: at, os: 'ios' }) + expect(r?.os).toBe('ios') + }) + it('drops an invalid os value to null rather than passing it through', () => { + const r = parseIncomingEvent({ source: 'ga4', eventName: 'install', occurredAt: at, os: 'windows' }) + expect(r?.os).toBeNull() + }) + it('defaults os to null when absent', () => { + const r = parseIncomingEvent({ source: 'ga4', eventName: 'install', occurredAt: at }) + expect(r?.os).toBeNull() + }) +}) diff --git a/src/lib/growth/ingest-parse.ts b/src/lib/growth/ingest-parse.ts index 821a921..ace061f 100644 --- a/src/lib/growth/ingest-parse.ts +++ b/src/lib/growth/ingest-parse.ts @@ -4,7 +4,7 @@ * a request. Unknown event names / sources are dropped, not trusted. */ -import { EVENTS, SOURCES, type ConversionEventInput, type EventName, type EventSource } from './events' +import { EVENTS, SOURCES, isOs, type ConversionEventInput, type EventName, type EventSource, type Os } from './events' import { resolveChannel, isChannel, type Channel } from './channels' const EVENT_SET = new Set(Object.values(EVENTS)) @@ -48,6 +48,9 @@ export function parseIncomingEvent(raw: unknown): ConversionEventInput | null { const revenue = typeof r.revenue === 'number' && Number.isFinite(r.revenue) ? r.revenue : 0 + const rawOs = str(r.os) + const os: Os | null = rawOs && isOs(rawOs) ? rawOs : null + return { source: source as EventSource, eventName: eventName as EventName, @@ -56,8 +59,15 @@ export function parseIncomingEvent(raw: unknown): ConversionEventInput | null { utmSource, utmCampaign: str(r.utmCampaign), channel, + os, country: str(r.country), revenue, + // agency (bi §7): the backend generally doesn't have one to send (Adjust's + // campaign-name parse is the primary source) — only trusted if explicitly + // provided, never inferred here. Normalized like every other producer of + // this dimension (parseCampaignName lowercases) so the funnel join's + // exact string match can't miss on case. + agency: str(r.agency)?.trim().toLowerCase() || undefined, raw, } } diff --git a/src/lib/growth/kpi-canon.test.ts b/src/lib/growth/kpi-canon.test.ts index 053524a..0af7d29 100644 --- a/src/lib/growth/kpi-canon.test.ts +++ b/src/lib/growth/kpi-canon.test.ts @@ -13,6 +13,14 @@ import { realizedLtv, projectSubscriberLtv, kFactor, + resolveInstallAuthority, + isMatureForRetentionWindow, + costPerSignup, + roi, + arpu, + arppu, + aggregateCohortWindow, + computeFunnelMetrics, } from './kpi-canon' describe('rate metrics', () => { @@ -92,3 +100,167 @@ describe('kFactor carries a confidence flag', () => { expect(kFactor(30, 300, true).confidence).toBe('measured') }) }) + +describe('resolveInstallAuthority — decision A + anti-zeroing guard', () => { + it('no Adjust auth → GA4 is authority', () => { + expect( + resolveInstallAuthority({ hasAdjustAuth: false, adjustInstallCount: 0, ga4InstallCount: 50 }), + ).toEqual({ authority: 'ga4', fallback: false }) + }) + it('S2S-only org (no legacy credential, live adjust events) → Adjust is authority', () => { + // The recommended setup wires only the callback route — the legacy + // Report-API credential must not be a precondition for authority. + expect( + resolveInstallAuthority({ hasAdjustAuth: false, adjustInstallCount: 40, ga4InstallCount: 50 }), + ).toEqual({ authority: 'adjust', fallback: false }) + }) + it('Adjust auth configured and reporting installs → Adjust is authority', () => { + expect( + resolveInstallAuthority({ hasAdjustAuth: true, adjustInstallCount: 40, ga4InstallCount: 50 }), + ).toEqual({ authority: 'adjust', fallback: false }) + }) + it('anti-zeroing: Adjust configured but 0 installs in window while GA4 has signal → falls back to GA4 with a warning', () => { + const r = resolveInstallAuthority({ hasAdjustAuth: true, adjustInstallCount: 0, ga4InstallCount: 30 }) + expect(r.authority).toBe('ga4') + expect(r.fallback).toBe(true) + expect(r.warning).toMatch(/adjust/i) + }) + it('does not fall back when both sources are legitimately 0', () => { + expect( + resolveInstallAuthority({ hasAdjustAuth: true, adjustInstallCount: 0, ga4InstallCount: 0 }), + ).toEqual({ authority: 'adjust', fallback: false }) + }) + it('does not fall back when the authority source has installs, even if lower than the other', () => { + expect( + resolveInstallAuthority({ hasAdjustAuth: true, adjustInstallCount: 5, ga4InstallCount: 100 }), + ).toEqual({ authority: 'adjust', fallback: false }) + }) +}) + +describe('isMatureForRetentionWindow — bi §6 D7-dilution gate', () => { + // Day N itself counts retention events across its full 24h (dayDiff === N), + // so maturity begins only when day N has ENDED: cohortDate + N + 1, UTC. + it('is mature once the whole of day cohortDate + N has elapsed', () => { + expect(isMatureForRetentionWindow('2026-07-01', 7, new Date('2026-07-09T00:00:00.000Z'))).toBe(true) + expect(isMatureForRetentionWindow('2026-07-01', 7, new Date('2026-07-09T00:00:00.001Z'))).toBe(true) + }) + it('is immature during day cohortDate + N — that day is still collecting events', () => { + expect(isMatureForRetentionWindow('2026-07-01', 7, new Date('2026-07-08T00:00:00.000Z'))).toBe(false) + expect(isMatureForRetentionWindow('2026-07-01', 7, new Date('2026-07-08T23:59:59.999Z'))).toBe(false) + }) + it('D1 matures a day earlier than D7', () => { + const now = new Date('2026-07-03T00:00:00.000Z') + expect(isMatureForRetentionWindow('2026-07-01', 1, now)).toBe(true) + expect(isMatureForRetentionWindow('2026-07-01', 7, now)).toBe(false) + }) +}) + +describe('bi §6 summary metrics', () => { + it('costPerSignup = spend / signups, null when no signups', () => { + expect(costPerSignup(100, 20)).toBeCloseTo(5) + expect(costPerSignup(100, 0)).toBeNull() + }) + it('roi = revenue / spend, null when spend is 0', () => { + expect(roi(150, 100)).toBeCloseTo(1.5) + expect(roi(150, 0)).toBeNull() + }) + it('arpu / arppu return 0 (not NaN) on zero denominator', () => { + expect(arpu(100, 0)).toBe(0) + expect(arppu(100, 0)).toBe(0) + expect(arpu(100, 50)).toBeCloseTo(2) + expect(arppu(100, 10)).toBeCloseTo(10) + }) +}) + +describe('aggregateCohortWindow — bi §7 shared cohort folding', () => { + const now = new Date('2026-07-10T00:00:00.000Z') + + it('sums cohortSize/signups/revenue across rows regardless of maturity', () => { + const agg = aggregateCohortWindow( + [ + { cohortDate: '2026-07-01', installs: 5, signups: 5, d1Retained: 2, d7Retained: 1, revenueD0: 10, revenueD7: 20 }, + { cohortDate: '2026-07-09', installs: 3, signups: 0, d1Retained: 1, d7Retained: 0, revenueD0: 5, revenueD7: 5 }, + ], + now, + ) + expect(agg.cohortSize).toBe(13) + expect(agg.signups).toBe(5) + expect(agg.revenueD0).toBe(15) + expect(agg.revenueD7).toBe(25) + }) + + it('gates d1/d7 numerator+denominator by maturity — immature cohorts excluded from both', () => { + // Maturity = day N has fully ENDED (cohortDate + N + 1, UTC). + // 2026-07-08: D1 day is 07-09, which ended at now (07-10) → D1 mature; + // D7 day is 07-15, far from over → D7 immature. + const agg = aggregateCohortWindow( + [{ cohortDate: '2026-07-08', installs: 10, signups: 0, d1Retained: 4, d7Retained: 9, revenueD0: 0, revenueD7: 0 }], + now, + ) + expect(agg.d1).toBe(4) + expect(agg.d1Base).toBe(10) + expect(agg.d7).toBe(0) + expect(agg.d7Base).toBe(0) + }) + + it('a cohort whose D1 day is still in progress stays out of the D1 rate entirely', () => { + // 2026-07-09's D1 day is 07-10 — the same day as `now`, still collecting. + const agg = aggregateCohortWindow( + [{ cohortDate: '2026-07-09', installs: 10, signups: 0, d1Retained: 4, d7Retained: 0, revenueD0: 0, revenueD7: 0 }], + now, + ) + expect(agg.d1).toBe(0) + expect(agg.d1Base).toBe(0) + }) + + it('empty input yields an all-zero aggregate', () => { + expect(aggregateCohortWindow([], now)).toEqual({ + cohortSize: 0, + signups: 0, + d1: 0, + d1Base: 0, + d7: 0, + d7Base: 0, + revenueD0: 0, + revenueD7: 0, + }) + }) +}) + +describe('computeFunnelMetrics — bi §7 shared funnel formula', () => { + it('derives costPerSignup/d1Rate/d7Rate/d0Roi/d7Roi from a window aggregate + spend', () => { + const result = computeFunnelMetrics({ + spend: 100, + signups: 20, + d1Retained: 4, + d1Base: 20, + d7Retained: 2, + d7Base: 20, + revenueD0: 50, + revenueD7: 150, + }) + expect(result.costPerSignup).toBeCloseTo(5) + expect(result.d1Rate).toBeCloseTo(0.2) + expect(result.d7Rate).toBeCloseTo(0.1) + expect(result.d0Roi).toBeCloseTo(0.5) + expect(result.d7Roi).toBeCloseTo(1.5) + }) + + it('costPerSignup and roi are null on zero spend/signups; rates are 0 not NaN', () => { + const result = computeFunnelMetrics({ + spend: 0, + signups: 0, + d1Retained: 0, + d1Base: 0, + d7Retained: 0, + d7Base: 0, + revenueD0: 0, + revenueD7: 0, + }) + expect(result.costPerSignup).toBeNull() + expect(result.d0Roi).toBeNull() + expect(result.d7Roi).toBeNull() + expect(result.d1Rate).toBe(0) + expect(result.d7Rate).toBe(0) + }) +}) diff --git a/src/lib/growth/kpi-canon.ts b/src/lib/growth/kpi-canon.ts index e1dd538..0cb1c66 100644 --- a/src/lib/growth/kpi-canon.ts +++ b/src/lib/growth/kpi-canon.ts @@ -79,6 +79,30 @@ export function costPerPayingUser(spend: number, payingUsers: number): number | return costPer(spend, payingUsers) } +/** Cost per signup = spend / signups (null if no signups). bi §6 summary view. */ +export function costPerSignup(spend: number, signups: number): number | null { + return costPer(spend, signups) +} + +/** + * ROI = revenue / spend for a given window (D0/D7). Null when spend is 0 — + * "infinite ROI on zero spend" is undefined, not a real number. bi §6. + */ +export function roi(revenue: number, spend: number): number | null { + if (spend <= 0) return null + return revenue / spend +} + +/** ARPU = revenue / cohortSize over a window (e.g. 7-day). 0 when cohortSize is 0. */ +export function arpu(revenue: number, cohortSize: number): number { + return rate(revenue, cohortSize) +} + +/** ARPPU = revenue / payingUsers over a window. 0 when there are no paying users. */ +export function arppu(revenue: number, payingUsers: number): number { + return rate(revenue, payingUsers) +} + /** * KOL effective CPI = cost / uplift installs. Uplift = installs above the * pre-publish baseline (hakko-kol-agent natural-uplift method). `costUsd` @@ -104,11 +128,41 @@ export function activationRate(activated: number, installs: number): number { * Retention rate = retained / cohortSize. Retained is measured on the calendar * day cohortDate + N (D1 → +1, D7 → +7), GA4 definition — a returning-session * count, not a rolling window. PRD targets: D1 ≥30%, D7 ≥18%. + * + * Callers aggregating multiple cohorts MUST pre-filter with + * `isMatureForRetentionWindow` before summing into `cohortSize` — an + * immature cohort (whose cohortDate+N hasn't happened yet) has a + * structural, not real, zero for D_N and dilutes the rate if counted in the + * denominator (bi §6 "D7 dilution" fix). */ export function retentionRate(retained: number, cohortSize: number): number { return rate(retained, cohortSize) } +/** + * True when a cohort's D_N retention window has fully elapsed as of `now`. + * Retention counts engagement across the WHOLE of calendar day + * cohortDate + N (`dayDiff === N` in cohorts.ts spans that day's full 24h), + * so the cohort is only final once day N has *ended* — i.e. at UTC midnight + * of cohortDate + N + 1, not at the first instant of day N. Gating at day + * N's start would fold cohorts into the D_N rate before that day's events + * could arrive — reintroducing, for a one-day band, the exact dilution this + * gate exists to fix. Cohorts that haven't reached the boundary have a + * structurally-zero D_N (not enough time to retain), so read-side + * aggregations must exclude them from both the numerator and denominator + * rather than counting them as "not retained". + * (bi §6, docs/growth/06-mmp-ingest.md §6.) + * + * @param cohortDate 'YYYY-MM-DD' (UTC) acquisition day. + * @param days retention horizon (1 for D1, 7 for D7). + */ +export function isMatureForRetentionWindow(cohortDate: string, days: number, now: Date = new Date()): boolean { + const cohortStart = Date.parse(cohortDate + 'T00:00:00.000Z') + if (!Number.isFinite(cohortStart)) return false + const matureAt = cohortStart + (days + 1) * 86_400_000 + return now.getTime() >= matureAt +} + /** Subscription rate = subscribers / installs. PRD target ≥2% within 90d. */ export function subscriptionRate(subscribers: number, installs: number): number { return rate(subscribers, installs) @@ -179,3 +233,160 @@ export function kFactor( confidence: attributionReady ? 'measured' : 'estimated', } } + +// ───────────────────────────────────────────────────────────────────────── +// Cohort-window aggregation — shared by /api/growth/summary and +// /api/reports/breakdown (bi §7 funnel↔spend bridge) so the D1/D7 maturity +// gate and the funnel-metric formulas live in exactly one place. +// ───────────────────────────────────────────────────────────────────────── + +/** The subset of CohortSnapshot fields needed to fold into a window aggregate. */ +export interface CohortWindowRow { + /** 'YYYY-MM-DD' (UTC) — CohortSnapshot.cohortDate's day key. */ + cohortDate: string + installs: number + signups: number + d1Retained: number + d7Retained: number + revenueD0: number + revenueD7: number +} + +export interface CohortWindowAgg { + /** installs + signups across all folded rows — the "acquired users" base. */ + cohortSize: number + signups: number + /** D1-retained numerator/denominator, gated by isMatureForRetentionWindow. */ + d1: number + d1Base: number + /** D7-retained numerator/denominator, gated by isMatureForRetentionWindow. */ + d7: number + d7Base: number + revenueD0: number + revenueD7: number +} + +/** + * Fold cohort-snapshot-shaped rows into window aggregates, applying the D1/D7 + * maturity gate so a cohort whose D_N day hasn't happened yet doesn't dilute + * the rate denominator (see isMatureForRetentionWindow's doc comment). Pure — + * `now` defaults to `new Date()` but is a parameter for deterministic tests. + */ +export function aggregateCohortWindow(rows: CohortWindowRow[], now: Date = new Date()): CohortWindowAgg { + const agg: CohortWindowAgg = { cohortSize: 0, signups: 0, d1: 0, d1Base: 0, d7: 0, d7Base: 0, revenueD0: 0, revenueD7: 0 } + for (const r of rows) { + const cohortSize = r.installs + r.signups + agg.cohortSize += cohortSize + agg.signups += r.signups + agg.revenueD0 += r.revenueD0 + agg.revenueD7 += r.revenueD7 + if (isMatureForRetentionWindow(r.cohortDate, 1, now)) { + agg.d1 += r.d1Retained + agg.d1Base += cohortSize + } + if (isMatureForRetentionWindow(r.cohortDate, 7, now)) { + agg.d7 += r.d7Retained + agg.d7Base += cohortSize + } + } + return agg +} + +export interface FunnelMetricsInput { + /** Actual media spend for this bucket (e.g. Report.spend, not CAC-derived). */ + spend: number + signups: number + d1Retained: number + d1Base: number + d7Retained: number + d7Base: number + revenueD0: number + revenueD7: number +} + +export interface FunnelMetricsResult { + costPerSignup: number | null + d1Rate: number + d7Rate: number + d0Roi: number | null + d7Roi: number | null +} + +/** + * Derive the funnel-metric columns (bi §6/§7) from a cohort-window aggregate + * plus a known spend figure. Pure composition of the formulas above — the + * single place both /api/growth/summary and /api/reports/breakdown compute + * these so the two views can never drift. + */ +export function computeFunnelMetrics(input: FunnelMetricsInput): FunnelMetricsResult { + return { + costPerSignup: costPerSignup(input.spend, input.signups), + d1Rate: retentionRate(input.d1Retained, input.d1Base), + d7Rate: retentionRate(input.d7Retained, input.d7Base), + d0Roi: roi(input.revenueD0, input.spend), + d7Roi: roi(input.revenueD7, input.spend), + } +} + +// ───────────────────────────────────────────────────────────────────────── +// Install authority — decision A (docs/growth/06-mmp-ingest.md §2) +// ───────────────────────────────────────────────────────────────────────── + +export type InstallAuthority = 'adjust' | 'ga4' + +export interface InstallAuthorityResult { + authority: InstallAuthority + /** True when we deviated from the configured authority (anti-zeroing guard). */ + fallback: boolean + /** Present only when `fallback` is true — surface it in cron logs / responses. */ + warning?: string +} + +/** + * Resolve which source is authoritative for INSTALL-class events in a + * ConversionEvent aggregation window. + * + * Rule (decision A): an org is "on Adjust" when it has live `source='adjust'` + * install events in the window (the S2S pipeline signal) OR a configured + * Adjust `PlatformAuth` (the legacy Report-API credential, treated as a hint + * only). Either makes Adjust the install/channel authority, demoting GA4 to + * funnel-deep events (first_chat, scene_generated, ...) that Adjust doesn't + * report. The recommended setup is S2S-only with NO legacy credential — so + * the credential must never be a precondition, otherwise those orgs' channel- + * attributed installs are silently excluded whenever GA4 has any installs. + * Orgs with neither signal keep GA4 as the (only) install source. + * This function is pure — callers do the `PlatformAuth` lookup and the + * per-source install counts (e.g. the growth-sync cron) and pass in + * primitives. + * + * Anti-zeroing guard: an org can have an Adjust `PlatformAuth` row for the + * legacy Report pull (`src/lib/platforms/adjust.ts`, `reports/sync`) without + * ever wiring the S2S callback route (`/api/ingest/adjust`) that actually + * populates ConversionEvent. If that happens, naively trusting "Adjust + * configured → Adjust authoritative" reports installs=0 for the whole window + * while GA4 still has real signal — a false funnel zero-out, not a real drop. + * When the resolved authority's window install count is 0 but the other + * source's count is > 0, we fall back to the other source and set + * `fallback: true` with a `warning` string so callers can log/surface it + * instead of silently reporting installs=0. + */ +export function resolveInstallAuthority(params: { + hasAdjustAuth: boolean + adjustInstallCount: number + ga4InstallCount: number +}): InstallAuthorityResult { + const { hasAdjustAuth, adjustInstallCount, ga4InstallCount } = params + const preferred: InstallAuthority = hasAdjustAuth || adjustInstallCount > 0 ? 'adjust' : 'ga4' + const other: InstallAuthority = preferred === 'adjust' ? 'ga4' : 'adjust' + const preferredCount = preferred === 'adjust' ? adjustInstallCount : ga4InstallCount + const otherCount = other === 'adjust' ? adjustInstallCount : ga4InstallCount + + if (preferredCount === 0 && otherCount > 0) { + return { + authority: other, + fallback: true, + warning: `install authority '${preferred}' had 0 installs in the window while '${other}' had ${otherCount} — falling back to '${other}' to avoid a false zero (check whether /api/ingest/adjust is actually wired for this org)`, + } + } + return { authority: preferred, fallback: false } +} diff --git a/src/lib/growth/revenuecat.test.ts b/src/lib/growth/revenuecat.test.ts index e35831e..058b54f 100644 --- a/src/lib/growth/revenuecat.test.ts +++ b/src/lib/growth/revenuecat.test.ts @@ -51,3 +51,17 @@ describe('mapRevenueCatEvent', () => { expect(mapRevenueCatEvent({ event: {} })).toBeNull() }) }) + +describe('mapRevenueCatEvent — os from store (bi §6)', () => { + it('maps app_store → ios, play_store → android, stripe → web', () => { + expect(mapRevenueCatEvent(body({ type: 'RENEWAL', price: 5, store: 'app_store' }))?.os).toBe('ios') + expect(mapRevenueCatEvent(body({ type: 'RENEWAL', price: 5, store: 'play_store' }))?.os).toBe('android') + expect(mapRevenueCatEvent(body({ type: 'RENEWAL', price: 5, store: 'stripe' }))?.os).toBe('web') + }) + it('leaves os null for stores we do not confidently map (e.g. amazon)', () => { + expect(mapRevenueCatEvent(body({ type: 'RENEWAL', price: 5, store: 'amazon' }))?.os).toBeNull() + }) + it('leaves os null when store is absent', () => { + expect(mapRevenueCatEvent(body({ type: 'RENEWAL', price: 5 }))?.os).toBeNull() + }) +}) diff --git a/src/lib/growth/revenuecat.ts b/src/lib/growth/revenuecat.ts index 4d7bb1c..e2999e4 100644 --- a/src/lib/growth/revenuecat.ts +++ b/src/lib/growth/revenuecat.ts @@ -13,7 +13,7 @@ * Ref: docs/growth/00-cuddler-first-redesign.md §5.1 */ -import { EVENTS, SOURCES, type ConversionEventInput, type EventName } from './events' +import { EVENTS, SOURCES, type ConversionEventInput, type EventName, type Os } from './events' function asNumber(x: unknown): number { if (typeof x === 'number' && Number.isFinite(x)) return x @@ -28,6 +28,28 @@ function asString(x: unknown): string | null { return typeof x === 'string' && x.length > 0 ? x : null } +/** + * RC's `event.store` (app_store | mac_app_store | play_store | amazon | + * stripe | rc_billing | promotional) tells us which storefront the purchase + * went through — a reasonable OS proxy since RC has no device OS field of + * its own. Conservative mapping (bi §6): only the three stores we can place + * confidently; everything else (amazon, promotional, unknown) is left null + * rather than guessed. + */ +function osFromStore(store: string | null): Os | null { + switch (store) { + case 'app_store': + return 'ios' + case 'play_store': + return 'android' + case 'stripe': + case 'rc_billing': + return 'web' + default: + return null + } +} + /** * Map one RevenueCat webhook body to a normalized event, or null if the event * type is not funnel-relevant (TEST, BILLING_ISSUE, PRODUCT_CHANGE, ...). @@ -74,6 +96,7 @@ export function mapRevenueCatEvent(payload: unknown): ConversionEventInput | nul const tsMs = asNumber(e.event_timestamp_ms) || asNumber(e.purchased_at_ms) if (!tsMs) return null const userKey = asString(e.app_user_id) ?? asString(e.original_app_user_id) + const store = asString(e.store)?.toLowerCase() ?? null return { source: SOURCES.REVENUECAT, @@ -81,6 +104,7 @@ export function mapRevenueCatEvent(payload: unknown): ConversionEventInput | nul occurredAt: new Date(tsMs), userKey, channel: null, // RC carries no UTM; attributed later via userKey join + os: osFromStore(store), country: asString(e.country_code), revenue, // gross RC-reported price; net-of-store-fee applied downstream raw: payload, diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index 6021841..7650030 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -267,6 +267,55 @@ const en: Dict = { 'notif.abandoned_deliveries': 'Abandoned webhook deliveries', 'notif.failed_decisions': 'Failed agent decisions', 'notif.unused_invites': 'Unused invite codes', + + // ===== Dashboard BI ===== + 'bi.dashboard.subtitle': 'Cross-platform delivery + growth funnel, by date range', + 'bi.filter.os': 'OS', + 'bi.filter.source': 'Source', + 'bi.filter.platform': 'Channel', + 'bi.filter.agency': 'Agency', + 'bi.os.ios': 'iOS', + 'bi.os.android': 'Android', + 'bi.os.web': 'PC', + 'bi.source.paid': 'Paid', + 'bi.source.organic': 'Organic', + 'bi.table.os_summary.title': 'OS × Source Summary', + 'bi.table.breakdown.title': 'Delivery Detail', + 'bi.table.totals': 'Total', + 'bi.table.mode.daily': 'By day', + 'bi.table.mode.aggregate': 'Aggregate range', + 'bi.table.mode.label': 'Display', + 'bi.col.date': 'Date', + 'bi.col.date_range': 'Range', + 'bi.col.os': 'OS', + 'bi.col.source': 'Source', + 'bi.col.platform': 'Channel', + 'bi.col.agency': 'Agency', + 'bi.col.spend': 'Spend', + 'bi.col.signups': 'Signups', + 'bi.col.cost_per_signup': 'Cost/Signup', + 'bi.col.d1_rate': 'D1', + 'bi.col.d7_rate': 'D7', + 'bi.col.d0_roi': 'D0 ROI', + 'bi.col.d7_roi': 'D7 ROI', + 'bi.col.subscription_rate': 'Pay rate', + 'bi.col.arpu7d': '7d ARPU', + 'bi.col.arppu7d': '7d ARPPU', + 'bi.col.trial_to_paid_rate': 'Trial→Paid', + 'bi.col.trials': 'Trials', + 'bi.col.subscribers': 'Payers', + 'bi.col.cost_per_paying_user': 'Cost/Payer', + 'bi.col.revenue_to_date': 'Revenue', + 'bi.col.revenue_to_date_title': 'Revenue to date of cohorts acquired in the range; for ranges ending today this equals in-range revenue', + 'bi.col.revenue_d0': 'D0 revenue', + 'bi.col.revenue_d7': 'D7 revenue', + 'bi.col.impressions': 'Impressions', + 'bi.col.clicks': 'Clicks', + 'bi.col.cpc': 'CPC', + 'bi.col.pending_title': 'Attribution join pending — funnel metrics not yet bridged from Report to CohortSnapshot', + 'bi.empty.summary': 'No cohort data for this range yet', + 'bi.empty.breakdown': 'No delivery data for this range yet', + 'bi.legacy.title': 'Legacy overview (kept below)', } const zh: Dict = { @@ -514,6 +563,55 @@ const zh: Dict = { 'notif.abandoned_deliveries': '回调投递失败', 'notif.failed_decisions': '失败的 Agent 决策', 'notif.unused_invites': '未使用的邀请码', + + // ===== 仪表盘 BI ===== + 'bi.dashboard.subtitle': '跨平台投放与增长漏斗,按日期区间查看', + 'bi.filter.os': '系统', + 'bi.filter.source': '来源', + 'bi.filter.platform': '渠道', + 'bi.filter.agency': '代理商', + 'bi.os.ios': 'iOS', + 'bi.os.android': 'Android', + 'bi.os.web': 'PC', + 'bi.source.paid': '付费', + 'bi.source.organic': '自然', + 'bi.table.os_summary.title': '分端汇总', + 'bi.table.breakdown.title': '投放明细', + 'bi.table.totals': '合计', + 'bi.table.mode.daily': '按天', + 'bi.table.mode.aggregate': '汇总区间', + 'bi.table.mode.label': '显示', + 'bi.col.date': '日期', + 'bi.col.date_range': '区间', + 'bi.col.os': '系统', + 'bi.col.source': '来源', + 'bi.col.platform': '渠道', + 'bi.col.agency': '代理商', + 'bi.col.spend': '花费', + 'bi.col.signups': '注册量', + 'bi.col.cost_per_signup': '注册成本', + 'bi.col.d1_rate': '次留率', + 'bi.col.d7_rate': '七留率', + 'bi.col.d0_roi': 'D0 ROI', + 'bi.col.d7_roi': 'D7 ROI', + 'bi.col.subscription_rate': '付费率', + 'bi.col.arpu7d': '7日ARPU', + 'bi.col.arppu7d': '7日ARPPU', + 'bi.col.trial_to_paid_rate': '试用付费率', + 'bi.col.trials': '试用人数', + 'bi.col.subscribers': '付费人数', + 'bi.col.cost_per_paying_user': '付费成本', + 'bi.col.revenue_to_date': '付费金额', + 'bi.col.revenue_to_date_title': '所选周期内获客的新用户累计付费金额;周期截至今天时即等于周期内付费金额', + 'bi.col.revenue_d0': '0日付费金额', + 'bi.col.revenue_d7': '7日付费金额', + 'bi.col.impressions': '展现', + 'bi.col.clicks': '点击', + 'bi.col.cpc': 'CPC', + 'bi.col.pending_title': '待归因打通 — Report 与 CohortSnapshot 尚未打通,漏斗指标暂不可用', + 'bi.empty.summary': '该区间暂无分端汇总数据', + 'bi.empty.breakdown': '该区间暂无投放明细数据', + 'bi.legacy.title': '历史总览(保留于下方)', } const DICT: Record = { en, zh } diff --git a/src/lib/platforms/adjust.ts b/src/lib/platforms/adjust.ts index f66707b..0f7dfb3 100644 --- a/src/lib/platforms/adjust.ts +++ b/src/lib/platforms/adjust.ts @@ -1,3 +1,10 @@ +/** + * Legacy Report-only path: app-level daily aggregates (no channel dimension) + * into `Report` for /dashboard. Do NOT feed this into ConversionEvent — for + * cohort-grade ingestion (S2S callbacks, channel mapping, install-source + * authority) see docs/growth/06-mmp-ingest.md first; wiring Adjust into the + * growth pipeline without those prerequisites double-counts installs. + */ export interface AdjustConfig { apiToken: string appToken: string diff --git a/src/lib/platforms/appsflyer.ts b/src/lib/platforms/appsflyer.ts index f1fd420..7842167 100644 --- a/src/lib/platforms/appsflyer.ts +++ b/src/lib/platforms/appsflyer.ts @@ -1,3 +1,8 @@ +/** + * Legacy Report-only path: date×pid aggregates into `Report` for /dashboard. + * Do NOT feed this into ConversionEvent — for cohort-grade ingestion the same + * prerequisites as Adjust apply (docs/growth/06-mmp-ingest.md §4). + */ export interface AppsFlyerConfig { apiToken: string appId: string diff --git a/src/lib/sync/report-writer.ts b/src/lib/sync/report-writer.ts index 6b276cb..9017f7b 100644 --- a/src/lib/sync/report-writer.ts +++ b/src/lib/sync/report-writer.ts @@ -1,6 +1,7 @@ import { prisma } from '@/lib/prisma' import type { PlatformAdapter, AccountReport, CampaignReport } from '@/lib/platforms/adapter' import { upsertPlatformLink } from '@/lib/platforms/links' +import { parseCampaignName } from '@/lib/growth/campaign-name' export type SyncMetrics = { impressions: number @@ -44,6 +45,24 @@ function fromCampaignReport(r: CampaignReport): SyncMetrics { return fromAccountReport(r) } +/** + * Media-buying agency of record for a platform, when one is on file + * (PlatformAccount.agency). Reads the primary account row for the platform; + * orgs without a PlatformAccount row at all (legacy single-token auth) or + * without an agency set simply get `null` — never guessed. Shared by the + * legacy (`reports/sync` route) and adapter-driven (below) write paths so + * there's a single fallback implementation. + */ +export async function resolveReportAgency(orgId: string, platform: string): Promise { + const account = await prisma.platformAccount.findFirst({ + where: { orgId, platform, isPrimary: true }, + }) + // Lowercase to match parseCampaignName's normalization — the funnel join + // (reports/breakdown) is an exact string match on agency, so a mixed-case + // value here ("GroupM") would permanently miss cohort rows ("groupm"). + return account?.agency?.trim().toLowerCase() || null +} + /** * Upsert one Report row at level=account. Uses the legacy * "${platform}-${orgId}-${endDate}" id so existing dashboard code keeps @@ -86,6 +105,16 @@ export async function writeAccountReport(opts: { * Upsert one Report row per campaign per day. Resolves (or creates) the * PlatformLink so that future agent reads can join Reports back to the local * campaign. Date is taken from each row, not opts.date. + * + * Agency (bi §7): campaign rows are stamped from the campaign name itself — + * `parseCampaignName(row.campaignName).agency` — since the agency who bought + * the media is encoded in the naming convention, not something the platform + * API reports directly. Priority: + * 1. campaign-name parse (per-row, most specific) + * 2. PlatformAccount.agency (`resolveReportAgency`, org/platform-level fallback) + * Rows with neither a parseable campaign name nor a PlatformAccount.agency on + * file are left `null` — never guessed. The org/platform-level fallback is + * resolved once per call (not per row) since it can't vary within a sync. */ export async function writeCampaignReports(opts: { orgId: string @@ -94,6 +123,8 @@ export async function writeCampaignReports(opts: { accountId: string rows: CampaignReport[] }) { + const fallbackAgency = await resolveReportAgency(opts.orgId, opts.platform) + for (const row of opts.rows) { if (!row.platformCampaignId) continue const link = await upsertPlatformLink({ @@ -114,6 +145,7 @@ export async function writeCampaignReports(opts: { const id = `${opts.platform}-${opts.orgId}-camp-${row.platformCampaignId}-${dateStr}` const metrics = fromCampaignReport(row) const d = derived(metrics) + const agency = parseCampaignName(row.campaignName)?.agency ?? fallbackAgency await prisma.report.upsert({ where: { id }, update: { @@ -121,6 +153,7 @@ export async function writeCampaignReports(opts: { ...d, level: 'campaign', campaignLinkId: link.id, + agency, rawData: JSON.stringify(row), }, create: { @@ -131,6 +164,7 @@ export async function writeCampaignReports(opts: { date, level: 'campaign', campaignLinkId: link.id, + agency, ...metrics, ...d, rawData: JSON.stringify(row),