diff --git a/.env.example b/.env.example index 9010f87..a0e9bb4 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,11 @@ DATABASE_URL="postgresql://user:password@host:5432/dbname" # Auth — HMAC secret for signing session cookies (REQUIRED in production). # Generate with: openssl rand -hex 32 AUTH_TOKEN_SECRET="generate-a-random-secret" +# Adjust API credentials require a stable, server-side encryption key: +# base64 of exactly 32 random bytes. Store the real value in Secret Manager, +# never in source. Unset blocks new connections. Rotation requires a plan to +# re-encrypt existing credentials or reconnect them with their API tokens. +PLATFORM_CREDENTIAL_KEY="" # Legacy NextAuth env — falls back to AUTH_TOKEN_SECRET if set NEXTAUTH_SECRET="generate-a-random-secret" NEXTAUTH_URL="http://localhost:3000" diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 11d2fa2..4d8cf1d 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -36,6 +36,10 @@ jobs: INGEST_WEBHOOK_SECRET: e2e-competitor-ingest-secret WORKER_WEBHOOK_SECRET: e2e-worker-secret INVITE_CODES_DISABLED: 'true' + REPORT_DB_TESTS: '1' + ADJUST_TEST_API_URL: http://127.0.0.1:3322 + # Fixture-only encryption key. Never deploy this value. + PLATFORM_CREDENTIAL_KEY: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= steps: - uses: actions/checkout@v4 @@ -66,9 +70,11 @@ jobs: run: npm run test:e2e - name: Upload Playwright report - if: failure() + if: always() uses: actions/upload-artifact@v4 with: name: playwright-report - path: playwright-report/ + path: | + playwright-report/ + test-results/ retention-days: 7 diff --git a/docs/agent/adjust-setup-validation.md b/docs/agent/adjust-setup-validation.md new file mode 100644 index 0000000..e42a778 --- /dev/null +++ b/docs/agent/adjust-setup-validation.md @@ -0,0 +1,147 @@ +# Adjust Setup Validation + +Date: 2026-09-24. Release branch `codex-mt/adjust-onboarding`, separated from +the broader report/model work. This document records pre-release verification; +live Adjust account acceptance remains pending the user's token and mappings. + +The release includes the minimum signed-session boundary: raw user-ID cookies +are rejected before the new credential routes. Existing signed sessions remain +supported; old raw-ID sessions must log in again. Seedance model/ownership and +the broader dashboard metric changes are not part of this branch. + +## Scope and Modules + +The first intended live products are Luddi and Cuddler. Their production app +tokens and events are not known or guessed. The same connection can discover +both apps, with independent product association, metrics, source rules, +currency, fixed UTC offset and attribution settings. Acquisition configuration +and credentials are not imported. + +- `src/lib/platforms/adjust.ts`: fixed Report Service transport and catalogs. +- `src/lib/reports/adjust-data.ts`: validation, traffic classification and + normalization, with no database or network access. +- `src/lib/reports/adjust-service.ts`: workspace ownership, configuration and + idempotent latest-window snapshot persistence. +- `src/lib/platform-credential.ts`: encrypted credential envelope. +- `src/app/api/adjust/`: connection, catalog, preview, save and sync routes. +- `src/components/adjust-setup.tsx` and `src/components/adjust/`: onboarding, + metric/source configuration, filtered report and CSV export. +- `prisma/migrations/20260924114500_adjust_report_snapshot/migration.sql`: + additive snapshot table; existing report/event records remain untouched. + +The UI is at `/settings/adjust`, honoring the deployment basePath. APIs and +reports require a signed session and active workspace; connection, catalog, +preview and writes require owner/admin. Members can read saved reports. + +## Acceptance Scenarios + +1. Given an authorized workspace, connecting a valid token discovers apps; + storage is encrypted and neither token nor ciphertext is returned by APIs. +2. Given two apps, each uses its own available metrics. A metric from the other + app is rejected. Selected count/user/revenue semantics require confirmation. +3. Given organic, known paid, conflicting or unknown sources, normalization + preserves raw labels/IDs and leaves ambiguity unknown. Exact per-app rules + can override classification; unknown is never treated as organic. +4. Given a saved app, repeated sync replaces one snapshot. Provider failure + preserves the last successful report, while a config change marks it stale. +5. Given a different workspace or anonymous caller, reject access before + contacting the provider. Runtime test endpoint overrides reject production. + Members cannot use either the dedicated or combined sync entry to bypass + the Adjust write restriction. +6. Given an app's opt-in automatic sync, select seven complete reporting dates + in that app's fixed UTC offset, not partially elapsed dates in another zone. + +No Adjust spend is added to media spend. Dimension-level unique users are not +summed into app-wide totals. Missing metrics are null, not inferred zeros. This +report does not yet supply a daily warehouse or matched media CPA/CPP/ROAS. + +## Local Verification + +Working directory: `/Users/mt/.codex/worktrees/adjust-onboarding-release/adex`. + +```sh +npm test +npx tsc --noEmit +npm run lint +env -u DATABASE_URL npx prisma generate +env -u DATABASE_URL npx prisma validate +env -u DATABASE_URL -u SEEDANCE2_API_KEY -u ANTHROPIC_API_KEY npm run build +env -u DATABASE_URL -u SEEDANCE2_API_KEY -u ANTHROPIC_API_KEY \ + -u WORKER_WEBHOOK_SECRET -u INGEST_WEBHOOK_SECRET PORT=3327 \ + npm run test:e2e -- --workers=2 --reporter=dot +``` + +- Focused Adjust tests: 42 passed in three files, including encryption, + malformed/failed responses, classification, metrics and timezone boundaries. +- Timezone boundary test was observed failing before implementation, then + passing. Negative validation changed the unknown-source fallback to organic: + two tests failed; restoring the intended behavior passed. +- Prisma client generation and schema validation passed without a database. +- `env -u DATABASE_URL npx prisma migrate dev --name adjust_report_snapshot` + failed because `datasource.url` is missing. No migration was applied. +- Scoped release unit run: 470 passed across 38 files, zero skipped. Type check + and production build passed. Lint has zero errors and 35 existing warnings + outside changed files; targeted lint of the final sync permission change + also passed. `git diff --check` passed. +- Local full E2E run: 59 total, 25 passed and 34 skipped. Database-dependent + cases, including all nine Adjust database cases, were skipped without + `DATABASE_URL`. This is not a successful database integration run; the new + member-permission and existing-user-cookie cases still require CI execution. + +Browser QA used an isolated Ego Lite task with fictional API responses and a +temporary preview route, now removed. Desktop 1440x1000 and mobile 390x844 +verified app switching, per-app events, preview, unknown-source filtering, +CSV download and preserving the report after a 502 failure. Mobile document +width was 390px; the report table scrolled within its container. + +Artifacts: `/tmp/adex-adjust-qa-20260924/desktop.png`, `mobile.png`, `unknown.csv`. +These precede the final component extraction, semantic-confirmation checkbox +and CSV metadata additions. They validate interaction/layout with fixtures, +not actual provider data or final database integration. + +## Database and Release Gates + +The nine database E2E cases in `e2e/adjust-setup.spec.ts` use real Postgres and a +local HTTP provider, not mocked Prisma. They require the existing isolated CI +database with `REPORT_DB_TESTS=1` and `ADJUST_TEST_API_URL=http://127.0.0.1:3322`. +The latter is rejected when `NODE_ENV=production`; never deploy it. CI's +encryption key is fixture-only. The PR's checks must verify these cases before +merge; local no-database checks alone are insufficient. + +The DB-backed UI case checks normalized preview values, cached-report access +when the provider catalog fails, saved metric labels and mobile overflow. It +records desktop/mobile screenshots under the workflow's `playwright-report` +artifact, including successful runs. Disconnect tests reject missing or +object-valued filters before any delete and preserve the connection for members. + +Before release: + +1. Pass the migration and full database E2E suite on an isolated test database. +2. Configure a stable `PLATFORM_CREDENTIAL_KEY` through the deployment secret + manager: base64 of exactly 32 random bytes. Do not put its value in source, + ordinary files or logs. Preserve it across revisions; key rotation requires + a re-encryption/reconnection plan. Missing/invalid keys block new connections. +3. New/replaced Adjust credentials are AES-256-GCM encrypted with workspace + binding. Legacy plaintext credentials remain readable for compatibility; + they are not silently migrated. Reconnect them through the secure page. +4. After release, enter the Adjust Report Service API token only in the secure + settings page. Select the actual Luddi and Cuddler apps; confirm registration + count versus users, unique payers, revenue definition, currency, UTC offset + and attribution settings. Missing unique-user metrics must remain unmapped. +5. Reconcile a fixed date window with Adjust's dashboard, including unknown + source rows, zero/missing values, totals and provider warnings. Only then + opt into existing daily sync. No new scheduler or live automatic run has + been created/enabled by this change. + +Fixed UTC offsets do not automatically follow daylight-saving transitions. +No custom external API URL import or Adjust OAuth account login is implemented. +Media-cost joins, durable daily history and cohort/maturity processing remain +separate work under [the reporting contract](../growth/07-media-adjust-reporting.md). + +## Recovery + +Before the first real Adjust credential is stored, reverting the Cloud Run +revision can leave the additive table and unused encryption key in place. +After encrypted credentials are stored, pre-feature clients cannot use them: +prefer a forward fix rather than blindly returning to the plaintext client. +Never rotate or delete the encryption key as part of a rollback. diff --git a/docs/growth/07-media-adjust-reporting.md b/docs/growth/07-media-adjust-reporting.md new file mode 100644 index 0000000..ef07f8c --- /dev/null +++ b/docs/growth/07-media-adjust-reporting.md @@ -0,0 +1,135 @@ +# Media APIs + Adjust Reporting Contract + +Date: 2026-09-24. Target source contract with a local Adjust setup implementation; +not a completed live integration or release. + +## Source Boundary + +Adex reporting should work with media APIs and Adjust, without depending on +data_agent/acquisition configuration, its databases, GA4, or RevenueCat. +Acquisition is a reference for presentation, ratio aggregation and quality +indicators only. Do not import its credentials, event mappings, product IDs, +campaign naming rules, short-link registry, or time corrections. + +- Media APIs own spend, impressions and clicks at a single daily fact grain. + Do not add Adjust cost to media cost or sum account/campaign/adgroup copies. +- Adjust owns attributed installs and configured conversion/revenue metrics. + Registration and purchase events must already reach Adjust from the product. + Connecting the reporting API cannot create missing product instrumentation. +- Product-specific behavior can be added only when that product sends a + corresponding Adjust event. Missing metrics stay unavailable, not zero. +- Raw events and aggregate report rows are different facts. Never synthesize + ConversionEvent records from aggregated Adjust metrics. + +## Metric Definitions + +| Metric | Required evidence | +| --- | --- | +| Spend, impressions, clicks | Media daily facts, currency and reporting timezone | +| Installs | Adjust attributed installs, attribution mode and app identity | +| Registrations and CPA | Configured registration metric with known count/user semantics; spend from matching dimensions and window | +| Paying users and CPP | A matching unique-payer metric; purchase event count and subscription activations are not substitutes | +| Revenue and ROAS | Explicit IAP/subscription/ad-revenue selection, currency, gross/net definition, cohort anchor and window | +| Net-profit ROI | Not available from advertising spend and revenue alone; requires the other business costs and adjustments | + +Recompute ratios from matched additive operands. Never average row-level CPA +or ROAS. Do not sum daily unique users to claim range-wide unique users; query +the required reporting period and a compatible provider metric. Keep calendar +revenue separate from cohort revenue, and define renewals/refunds explicitly. + +Adjust's documented cohorts start at install or reattribution, not registration. +Its D0 is the first 24 hours; a cumulative D6 covers the first seven days. +Do not relabel an Adjust D7 metric as registration-based 168-hour ROAS. Exact +registration cohorts require suitable timestamped raw Adjust data and stable +identity, plus separate ingestion work; the aggregate API alone is insufficient +evidence for that definition. See [How cohorts work](https://help.adjust.com/en/article/how-cohorts-work). + +The report API's `cohort_maturity=mature` can return zeros for immature cohorts. +Persist/derive maturity separately so an unavailable window is not a true zero. +Pin attribution source, reattribution inclusion, currency and timezone rather +than relying on account defaults. Keep SKAdNetwork and other attribution views +separate unless an explicit non-overlapping reconciliation is verified. See the +[JSON report contract](https://dev.adjust.com/en/api/rs-api/reports/). + +## Adex-Owned Configuration + +For each product, resolve its Adjust app identifiers, media account/campaign +IDs, available registration/payment event metrics, revenue scope, currency, +timezone and attribution/cohort definition. These are product-specific values, +not copied acquisition settings. Secrets remain in the existing credential +store; documentation, fixtures and ordinary configuration must not contain them. + +Discover actual metric IDs using Adjust's [Events endpoint](https://dev.adjust.com/en/api/rs-api/events/) +and [Filters Data endpoint](https://dev.adjust.com/en/api/rs-api/filters-data/). +An event token is not necessarily a Report Service metric slug. Availability +must be verified for the connected account and app, not guessed from labels. + +Join only compatible facts using product/app, media platform, account/campaign +IDs and reporting period. Include OS/country only when both sources support +those dimensions. Never copy one campaign's total cost into every OS/product +slice. Missing IDs stay unmatched; names are display labels, not join keys. + +## Implemented Local Setup + +- `/settings/adjust` connects a Report Service API token, discovers accessible + apps/events, and saves a separate configuration for each app. Luddi and + Cuddler are the first intended live acceptance targets, not preconfigured + production app IDs. Connecting an account is not an OAuth login flow. +- New credentials use authenticated encryption. Fixed official endpoints, + workspace-scoped access and admin-only connection/configuration avoid an + arbitrary URL credential forwarder. Tokens are never returned to the UI. +- Preview and sync retain raw network/partner and media IDs, then classify + organic, known paid, explicitly mapped other, or unknown traffic. Conflicts + and unmapped sources remain unknown; exact per-app overrides are supported. +- Event count, unique-user and revenue semantics require explicit confirmation. + Missing mappings/values stay unavailable. Provider app-wide totals are queried + separately rather than summing dimension-level unique users. +- `AdjustReportSnapshot` stores one latest successful requested-window report + per configured app. Repeat sync replaces it; provider failures retain it; + changed configuration marks it stale. This is not a daily fact warehouse. +- Configured apps bypass legacy Adjust account-report writes in manual/daily + sync. Automatic sync is opt-in and uses the latest seven complete dates in + each app's configured fixed UTC offset. No scheduler was activated. + +See [local validation and release gates](../agent/adjust-setup-validation.md). + +## Remaining Gaps + +- Migration, database integration tests and real account reconciliation remain + unverified locally. Catalog availability and selected metric semantics must + be checked against the actual Luddi/Cuddler accounts before trusting metrics. +- The new report does not yet join media costs or calculate matched CPA, CPP, + or ROAS. Independent daily facts, cohort windows and historical snapshots + remain follow-up work; do not treat the latest-window snapshot as those facts. +- Unconfigured legacy Adjust connections retain their existing rolling account + snapshot path. No historical rows were reinterpreted or backfilled. +- `growth/adjust-ingest.ts` currently emits zero revenue and uses a callback + user key that can change after registration. It is not an Adjust-only payment + or registration-cohort implementation. +- Existing growth snapshots can mix GA4/RevenueCat/Adjust events, lack complete + product separation, and have their own historical UTC-day revenue windows. + Do not expose them as newly validated Adjust-only metrics or silently backfill + them with a different meaning. +- Existing media writers do not provide every matching dimension (notably OS) + or a complete currency contract. Fine-grained matched costs may be unavailable. +- `getCohortReport` retains a legacy metric selection. No claim is made that it + supplies configured registration metrics or the required cumulative windows. + +## Integration Acceptance Gate + +Before enabling the new source mode, use sanitized fixtures based on the target +app's available fields, then an explicitly authorized read-only reconciliation: + +1. Verify event availability, unique-user semantics, app/account/campaign IDs, + timezone, currency, attribution settings and revenue scope. +2. Persist independent daily media and Adjust aggregate facts with idempotent + keys; query them without multiplying spend or mixing legacy event snapshots. +3. Test missing mappings, unmatched dimensions, currency mismatch, immature + windows, unknown values, provider errors and repeated synchronization. +4. Reconcile a fixed mature date range with both source dashboards, including + unmatched totals and freshness. Do not equate a connected account or HTTP + 200 with correct registrations, payer counts or revenue. + +No live Adjust queries, real credentials or production writes were used. +A local additive schema/migration is prepared but has not been applied to a +database. API documentation was checked on 2026-09-24. diff --git a/e2e/adjust-setup.spec.ts b/e2e/adjust-setup.spec.ts new file mode 100644 index 0000000..80a3b01 --- /dev/null +++ b/e2e/adjust-setup.spec.ts @@ -0,0 +1,416 @@ +import crypto from 'node:crypto' +import http from 'node:http' +import pg from 'pg' +import { + test, + expect, + request as pwRequest, + type APIRequestContext, +} from '@playwright/test' + +const p = (path: string) => `${process.env.NEXT_PUBLIC_BASE_PATH || ''}${path}` +test('Adjust endpoints reject anonymous access before contacting a provider', async ({ + request, +}) => { + for (const endpoint of [ + '/api/adjust', + '/api/adjust/catalog', + '/api/adjust/reports?appToken=luddi', + ]) { + expect((await request.get(p(endpoint))).status()).toBe(401) + } + expect( + ( + await request.post(p('/api/adjust'), { data: { token: 'fixture' } }) + ).status(), + ).toBe(401) +}) +test.describe('Adjust setup with a real isolated database and local HTTP provider', () => { + test.describe.configure({ mode: 'serial' }) + test.skip( + !process.env.DATABASE_URL || + process.env.REPORT_DB_TESTS !== '1' || + process.env.ADJUST_TEST_API_URL !== 'http://127.0.0.1:3322', + 'requires isolated DB and local Adjust fixture', + ) + let ctx: APIRequestContext + let pool: pg.Pool + let orgId: string + let failing = false + let calls = 0 + const server = http.createServer((req, res) => { + calls++ + const url = new URL(req.url!, 'http://127.0.0.1:3322') + res.setHeader('Content-Type', 'application/json') + if (failing) { + res.statusCode = 503 + res.end('{"message":"fixture unavailable"}') + return + } + const app = url.searchParams.get('app_token__in') + if (url.pathname === '/filters_data') + res.end( + JSON.stringify({ + apps: [ + { id: 'luddi', name: 'Luddi' }, + { id: 'cuddler', name: 'Cuddler' }, + ], + }), + ) + else if (url.pathname === '/events') + res.end( + JSON.stringify([ + { + id: `${app}_signup`, + name: 'Registration', + app_token: [app], + is_skad_event: false, + }, + ]), + ) + else + res.end( + JSON.stringify({ + rows: + url.searchParams.get('dimensions') === 'app_token' + ? [{ app_token: app, installs: '10', [`${app}_signup`]: '4' }] + : [ + { + app_token: app, + network: 'Organic', + partner: 'Organic', + installs: '6', + [`${app}_signup`]: '3', + }, + { + app_token: app, + partner: 'facebook', + network: 'Facebook Installs', + campaign_id_network: '123', + installs: '4', + [`${app}_signup`]: '1', + }, + ], + pagination: null, + }), + ) + }) + const plan = (app: string) => ({ + version: 1, + appToken: app, + appName: app, + productId: null, + currency: 'USD', + utcOffset: '+00:00', + attributionSource: 'first', + reattributed: 'false', + autoSync: false, + metricsConfirmed: true, + metrics: { + registration: { + id: `${app}_signup`, + name: 'Registration', + kind: 'events', + }, + }, + sourceRules: [], + }) + test.beforeAll(async ({}, info) => { + await new Promise((resolve) => + server.listen(3322, '127.0.0.1', resolve), + ) + pool = new pg.Pool({ connectionString: process.env.DATABASE_URL }) + ctx = await pwRequest.newContext({ baseURL: info.project.use.baseURL }) + const suffix = crypto.randomUUID() + expect( + ( + await ctx.post(p('/api/auth/register'), { + data: { + email: `adjust-${suffix}@adex-e2e.dev`, + password: 'fixture-password-1', + name: `Adjust ${suffix.slice(0, 8)}`, + }, + }) + ).ok(), + ).toBeTruthy() + const org = await ctx.post(p('/api/orgs'), { + data: { name: `Adjust ${suffix}` }, + }) + orgId = (await org.json()).id + expect( + (await ctx.post(p('/api/orgs/switch'), { data: { orgId } })).ok(), + ).toBeTruthy() + }) + test.afterAll(async () => { + await pool?.end() + await ctx?.dispose() + if (server.listening) + await new Promise((resolve) => server.close(() => resolve())) + }) + test('an existing user ID is not accepted as a session credential', async ({ + request, + }) => { + const { rows } = await pool.query( + 'SELECT "userId" FROM "OrgMembership" WHERE "orgId"=$1', + [orgId], + ) + const before = calls + const response = await request.get(p('/api/adjust'), { + headers: { Cookie: `auth_token=${rows[0].userId}` }, + }) + expect(response.status()).toBe(401) + expect(calls).toBe(before) + }) + test('connects once without returning or storing a plaintext token', async () => { + const res = await ctx.post(p('/api/adjust'), { + data: { token: 'fixture-not-a-real-token' }, + }) + expect(res.status()).toBe(200) + expect(await res.text()).not.toContain('fixture-not-a-real-token') + const { rows } = await pool.query( + 'SELECT "apiKey" FROM "PlatformAuth" WHERE "orgId"=$1 AND platform=\'adjust\'', + [orgId], + ) + expect(rows[0].apiKey).toMatch(/^enc:v1:/) + for (const endpoint of ['/api/platforms', '/api/adjust']) + expect(await (await ctx.get(p(endpoint))).text()).not.toContain( + rows[0].apiKey, + ) + }) + test('discovers two apps and rejects cross-app event mappings', async () => { + expect( + (await (await ctx.get(p('/api/adjust/catalog'))).json()).apps, + ).toHaveLength(2) + const wrong = { ...plan('luddi'), metrics: plan('cuddler').metrics } + expect( + (await ctx.put(p('/api/adjust'), { data: { plan: wrong } })).status(), + ).toBe(400) + }) + test('previews, saves and synchronizes each app without multiplying rows', async () => { + for (const app of ['luddi', 'cuddler']) { + const preview = await ctx.post(p('/api/adjust/reports'), { + data: { + plan: plan(app), + startDate: '2026-09-01', + endDate: '2026-09-07', + }, + }) + expect(preview.status()).toBe(200) + expect( + (await preview.json()).data.rows.map( + (row: { traffic: string }) => row.traffic, + ), + ).toEqual(['organic', 'paid']) + expect( + ( + await ctx.put(p('/api/adjust'), { data: { plan: plan(app) } }) + ).status(), + ).toBe(200) + for (let i = 0; i < 2; i++) + expect( + ( + await ctx.put(p('/api/adjust/reports'), { + data: { + appToken: app, + startDate: '2026-09-01', + endDate: '2026-09-07', + }, + }) + ).status(), + ).toBe(200) + } + const { rows } = await pool.query( + 'SELECT count(*)::int AS count FROM "AdjustReportSnapshot" WHERE "orgId"=$1', + [orgId], + ) + expect(rows[0].count).toBe(2) + }) + test('provider failure preserves the previous successful snapshot', async () => { + const before = await ( + await ctx.get(p('/api/adjust/reports?appToken=luddi')) + ).json() + failing = true + const response = await ctx.put(p('/api/adjust/reports'), { + data: { + appToken: 'luddi', + startDate: '2026-09-01', + endDate: '2026-09-07', + }, + }) + failing = false + expect(response.status()).toBe(502) + expect( + await (await ctx.get(p('/api/adjust/reports?appToken=luddi'))).json(), + ).toEqual(before) + }) + test('saved reports remain readable when event discovery is unavailable', async ({ + page, + }, info) => { + await page.context().addCookies((await ctx.storageState()).cookies) + await page.setViewportSize({ width: 1440, height: 1000 }) + await page.goto(p('/settings/adjust')) + await page + .getByLabel('Adjust 应用', { exact: true }) + .selectOption('cuddler') + await expect( + page.getByRole('heading', { name: 'cuddler / 报表' }), + ).toBeVisible() + await page.getByLabel('UTC 偏移', { exact: true }).fill(' +00:00 ') + await page.getByRole('button', { name: '预览', exact: true }).click() + await expect(page.getByLabel('UTC 偏移', { exact: true })).toHaveValue( + '+00:00', + ) + await expect( + page.getByRole('button', { name: '保存并同步', exact: true }), + ).toBeEnabled() + failing = true + try { + await page + .getByLabel('Adjust 应用', { exact: true }) + .selectOption('luddi') + await expect( + page.getByRole('heading', { name: 'luddi / 报表' }), + ).toBeVisible() + await expect(page.getByRole('main').getByRole('alert')).toContainText( + 'Adjust request failed', + ) + await expect(page.getByLabel('注册指标', { exact: true })).toHaveValue( + 'luddi_signup', + ) + await expect( + page + .locator('option:checked') + .filter({ hasText: 'Registration / luddi_signup' }), + ).toHaveCount(1) + await page.screenshot({ + path: info.outputPath('adjust-desktop.png'), + fullPage: true, + }) + await page.setViewportSize({ width: 390, height: 844 }) + await expect + .poll(() => + page.getByRole('complementary').evaluate( + (element) => element.getBoundingClientRect().right, + ), + ) + .toBeLessThanOrEqual(0) + expect( + await page.evaluate(() => document.documentElement.scrollWidth), + ).toBeLessThanOrEqual(390) + await page.screenshot({ + path: info.outputPath('adjust-mobile.png'), + fullPage: true, + }) + } finally { + failing = false + } + }) + test('configuration changes mark the prior snapshot stale until refreshed', async () => { + const changed = { + ...plan('luddi'), + sourceRules: [ + { + network: 'Facebook Installs', + partner: 'facebook', + traffic: 'other', + platform: 'meta', + }, + ], + } + expect( + (await ctx.put(p('/api/adjust'), { data: { plan: changed } })).status(), + ).toBe(200) + const stale = await ( + await ctx.get(p('/api/adjust/reports?appToken=luddi')) + ).json() + expect(stale.stale).toBe(true) + expect(stale.data.rows[1].traffic).toBe('paid') + const synced = await ctx.put(p('/api/adjust/reports'), { + data: { + appToken: 'luddi', + startDate: '2026-09-01', + endDate: '2026-09-07', + }, + }) + expect((await synced.json()).data.rows[1].traffic).toBe('other') + }) + test('members can read but cannot trigger Adjust writes through either sync entry', async () => { + await pool.query( + 'UPDATE "OrgMembership" SET role=\'member\' WHERE "orgId"=$1', + [orgId], + ) + const before = calls + try { + expect( + (await ctx.get(p('/api/adjust/reports?appToken=luddi'))).status(), + ).toBe(200) + expect( + ( + await ctx.put(p('/api/adjust/reports'), { + data: { + appToken: 'luddi', + startDate: '2026-09-01', + endDate: '2026-09-07', + }, + }) + ).status(), + ).toBe(403) + const combined = await ctx.post(p('/api/reports/sync')) + expect(combined.status()).toBe(200) + expect((await combined.json()).results.adjust.error).toBe( + 'Workspace admin access required', + ) + for (const endpoint of ['/api/platforms', '/api/platforms/accounts']) { + for (const data of [ + {}, + { platform: { not: 'unknown' }, accountId: { not: 'unknown' } }, + ]) { + expect((await ctx.delete(p(endpoint), { data })).status()).toBe(400) + } + expect( + ( + await ctx.delete(p(endpoint), { + data: { platform: 'adjust', accountId: 'luddi' }, + }) + ).status(), + ).toBe(403) + } + expect( + (await ctx.get(p('/api/adjust/reports?appToken=luddi'))).status(), + ).toBe(200) + expect((await (await ctx.get(p('/api/adjust'))).json()).connected).toBe( + true, + ) + expect(calls).toBe(before) + } finally { + await pool.query( + 'UPDATE "OrgMembership" SET role=\'owner\' WHERE "orgId"=$1', + [orgId], + ) + } + }) + test('a different workspace cannot read or sync this connection', async () => { + const other = await ctx.post(p('/api/orgs'), { + data: { name: 'Other Adjust workspace' }, + }) + await ctx.post(p('/api/orgs/switch'), { + data: { orgId: (await other.json()).id }, + }) + const before = calls + expect( + (await ctx.get(p('/api/adjust/reports?appToken=luddi'))).status(), + ).toBe(404) + expect( + ( + await ctx.put(p('/api/adjust/reports'), { + data: { + appToken: 'luddi', + startDate: '2026-09-01', + endDate: '2026-09-07', + }, + }) + ).status(), + ).toBe(404) + expect(calls).toBe(before) + }) +}) diff --git a/prisma/migrations/20260924114500_adjust_report_snapshot/migration.sql b/prisma/migrations/20260924114500_adjust_report_snapshot/migration.sql new file mode 100644 index 0000000..965dcc7 --- /dev/null +++ b/prisma/migrations/20260924114500_adjust_report_snapshot/migration.sql @@ -0,0 +1,14 @@ +CREATE TABLE "AdjustReportSnapshot" ( + "accountId" TEXT NOT NULL, + "orgId" TEXT NOT NULL, + "configHash" TEXT NOT NULL, + "startDate" TEXT NOT NULL, + "endDate" TEXT NOT NULL, + "data" TEXT NOT NULL, + "syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "AdjustReportSnapshot_pkey" PRIMARY KEY ("accountId") +); + +CREATE INDEX "AdjustReportSnapshot_orgId_idx" ON "AdjustReportSnapshot"("orgId"); +ALTER TABLE "AdjustReportSnapshot" ADD CONSTRAINT "AdjustReportSnapshot_accountId_fkey" + FOREIGN KEY ("accountId") REFERENCES "PlatformAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e575bef..9367a05 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -254,11 +254,28 @@ model PlatformAccount { updatedAt DateTime @updatedAt org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) + adjustSnapshot AdjustReportSnapshot? @@unique([orgId, platform, accountId]) @@index([orgId, platform, isPrimary]) } +// One complete, latest requested-window snapshot per configured Adjust app. +// Never sum overlapping snapshots or insert these aggregates as user events. +model AdjustReportSnapshot { + accountId String @id + orgId String + configHash String + startDate String + endDate String + data String + syncedAt DateTime @default(now()) + + account PlatformAccount @relation(fields: [accountId], references: [id], onDelete: Cascade) + + @@index([orgId]) +} + model Campaign { id String @id @default(cuid()) orgId String diff --git a/src/app/(dashboard)/settings/adjust/page.tsx b/src/app/(dashboard)/settings/adjust/page.tsx new file mode 100644 index 0000000..41b851f --- /dev/null +++ b/src/app/(dashboard)/settings/adjust/page.tsx @@ -0,0 +1,5 @@ +import { AdjustSetup } from '@/components/adjust-setup' + +export default function AdjustSettingsPage() { + return +} diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index b9d064b..09b1c9d 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -2,6 +2,7 @@ import { useEffect, useState, useCallback } from 'react' import { useRouter } from 'next/navigation' +import Link from 'next/link' import { Button } from '@/components/ui/button' import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card' import { Input } from '@/components/ui/input' @@ -20,7 +21,7 @@ interface PlatformAuth { platform: string accountId: string | null appId: string | null - apiKey: string | null + hasApiKey: boolean isActive: boolean extra: string | null hasRefreshToken?: boolean @@ -177,7 +178,6 @@ export default function SettingsPage() { newFormData[auth.platform] = {} if (auth.accountId) newFormData[auth.platform].accountId = auth.accountId if (auth.appId) newFormData[auth.platform].appId = auth.appId - if (auth.apiKey) newFormData[auth.platform].apiKey = auth.apiKey } setFormData(prev => { // Merge: keep any user-edited values, fill in saved values for empty fields @@ -488,6 +488,24 @@ export default function SettingsPage() {
{PLATFORMS.map((p) => { const auth = auths.find(a => a.platform === p.id) + if (p.id === 'adjust') return ( + + Adjust + + + 应用、事件映射与归因报表 + +

+ {auth?.isActive ? 'Connected' : 'Not connected'} +

+ {auth?.isActive && ( + + )} +
+
+ ) return ( @@ -545,7 +563,7 @@ export default function SettingsPage() { setFieldValue(p.id, f.key, e.target.value)} /> diff --git a/src/app/api/adjust/_shared.ts b/src/app/api/adjust/_shared.ts new file mode 100644 index 0000000..adbcd97 --- /dev/null +++ b/src/app/api/adjust/_shared.ts @@ -0,0 +1,58 @@ +import { NextResponse } from 'next/server' +import { requireAuthWithOrg } from '@/lib/auth' +import { AdjustSetupError } from '@/lib/reports/adjust-service' +import { isRecord } from '@/lib/reports/adjust-data' + +export async function readBody(req: Request): Promise> { + const raw = await req.text() + if (raw.length > 64_000) + throw new AdjustSetupError(413, 'Request is too large') + try { + const data: unknown = JSON.parse(raw) + if (!isRecord(data)) throw new Error('Invalid object') + return data + } catch { + throw new AdjustSetupError(400, 'Expected a JSON object') + } +} + +export async function withAdjustAuth( + write: boolean, + work: ( + context: Awaited>, + ) => Promise, +) { + let context: Awaited> + try { + context = await requireAuthWithOrg() + } catch { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + if (write && !['owner', 'admin'].includes(context.role)) + return NextResponse.json( + { error: 'Workspace admin access required' }, + { status: 403 }, + ) + try { + return NextResponse.json(await work(context), { + headers: { 'Cache-Control': 'no-store' }, + }) + } catch (error) { + if (error instanceof AdjustSetupError) + return NextResponse.json( + { error: error.message }, + { status: error.status }, + ) + const message = error instanceof Error ? error.message : '' + if (message.startsWith('Configure PLATFORM_CREDENTIAL_KEY')) + return NextResponse.json( + { error: 'Credential encryption is not configured on this server' }, + { status: 503 }, + ) + // Do not expose provider bodies, tokens, database errors or request URLs. + return NextResponse.json( + { error: 'Adjust request failed; no report was overwritten' }, + { status: 502 }, + ) + } +} diff --git a/src/app/api/adjust/catalog/route.ts b/src/app/api/adjust/catalog/route.ts new file mode 100644 index 0000000..f354d8c --- /dev/null +++ b/src/app/api/adjust/catalog/route.ts @@ -0,0 +1,15 @@ +import { NextRequest } from 'next/server' +import { adjustClient, AdjustSetupError } from '@/lib/reports/adjust-service' +import { withAdjustAuth } from '../_shared' + +export async function GET(req: NextRequest) { + return withAdjustAuth(true, async ({ org }) => { + const appToken = req.nextUrl.searchParams.get('appToken') || '' + const client = await adjustClient(org.id, appToken) + const apps = await client.listApps() + if (!appToken) return { apps } + if (!apps.some((app) => app.id === appToken)) + throw new AdjustSetupError(404, 'App is not accessible') + return { events: await client.listEvents() } + }) +} diff --git a/src/app/api/adjust/reports/route.ts b/src/app/api/adjust/reports/route.ts new file mode 100644 index 0000000..9c213ea --- /dev/null +++ b/src/app/api/adjust/reports/route.ts @@ -0,0 +1,41 @@ +import { NextRequest } from 'next/server' +import { + parsePlan, + parseRange, + previewAdjust, + readAdjustSnapshot, + syncAdjustApp, +} from '@/lib/reports/adjust-service' +import { withAdjustAuth, readBody } from '../_shared' + +export async function GET(req: NextRequest) { + return withAdjustAuth(false, ({ org }) => + readAdjustSnapshot(org.id, req.nextUrl.searchParams.get('appToken') || ''), + ) +} + +export async function POST(req: NextRequest) { + return withAdjustAuth(true, async ({ org }) => { + const body = await readBody(req) + const range = parseRange(body.startDate, body.endDate) + return previewAdjust( + org.id, + parsePlan(body.plan), + range.startDate, + range.endDate, + ) + }) +} + +export async function PUT(req: NextRequest) { + return withAdjustAuth(true, async ({ org }) => { + const body = await readBody(req) + const range = parseRange(body.startDate, body.endDate) + return syncAdjustApp( + org.id, + String(body.appToken || ''), + range.startDate, + range.endDate, + ) + }) +} diff --git a/src/app/api/adjust/route.ts b/src/app/api/adjust/route.ts new file mode 100644 index 0000000..1633eea --- /dev/null +++ b/src/app/api/adjust/route.ts @@ -0,0 +1,72 @@ +import { NextRequest } from 'next/server' +import { prisma } from '@/lib/prisma' +import { logAudit } from '@/lib/audit' +import { + connectAdjust, + parsePlan, + saveAdjustPlan, +} from '@/lib/reports/adjust-service' +import { withAdjustAuth, readBody } from './_shared' + +export async function GET() { + return withAdjustAuth(false, async ({ org, role }) => { + const connection = await prisma.platformAuth.findUnique({ + where: { orgId_platform: { orgId: org.id, platform: 'adjust' } }, + select: { isActive: true, apiKey: true }, + }) + const accounts = await prisma.platformAccount.findMany({ + where: { orgId: org.id, platform: 'adjust', isActive: true }, + select: { extra: true }, + }) + const plans = accounts.flatMap((account) => { + try { + return [parsePlan(JSON.parse(account.extra || '{}'))] + } catch { + return [] + } + }) + const products = await prisma.promotedApp.findMany({ + where: { orgId: org.id }, + select: { id: true, name: true, platform: true }, + orderBy: { name: 'asc' }, + }) + return { + connected: !!connection?.isActive && !!connection.apiKey, + canManage: role !== 'member', + plans, + products, + } + }) +} + +export async function POST(req: NextRequest) { + return withAdjustAuth(true, async ({ org, user }) => { + const { token } = await readBody(req) + const result = await connectAdjust(org.id, user.id, token) + await logAudit({ + orgId: org.id, + userId: user.id, + action: 'platform.connect', + targetType: 'platform', + targetId: 'adjust', + req, + }) + return result + }) +} + +export async function PUT(req: NextRequest) { + return withAdjustAuth(true, async ({ org, user }) => { + const { plan } = await readBody(req) + const result = await saveAdjustPlan(org.id, parsePlan(plan)) + await logAudit({ + orgId: org.id, + userId: user.id, + action: 'platform.connect', + targetType: 'adjust_app', + targetId: result.id, + req, + }) + return result + }) +} diff --git a/src/app/api/cron/daily/route.ts b/src/app/api/cron/daily/route.ts index 0c62f20..083384c 100644 --- a/src/app/api/cron/daily/route.ts +++ b/src/app/api/cron/daily/route.ts @@ -5,6 +5,8 @@ import { sendMail } from '@/lib/mailer' import { completeText, isLLMConfigured } from '@/lib/llm' import { AppsFlyerClient } from '@/lib/platforms/appsflyer' import { AdjustClient } from '@/lib/platforms/adjust' +import { openCredential } from '@/lib/platform-credential' +import { syncConfiguredAdjust } from '@/lib/reports/adjust-service' import { getAdapter, isAdaptablePlatform } from '@/lib/platforms/registry' import { runAdapterSync } from '@/lib/sync/report-writer' import { refreshBudgetSpent } from '@/lib/budget/refresh' @@ -94,7 +96,7 @@ async function syncOne( return metrics } if (platform === 'adjust' && auth.apiKey && auth.appId) { - const client = new AdjustClient({ apiToken: auth.apiKey, appToken: auth.appId }) + const client = new AdjustClient({ apiToken: openCredential(auth.apiKey, `${auth.orgId}:adjust`), appToken: auth.appId }) const data = await client.getReport(startDate, endDate) const rows: Array> = Array.isArray(data.rows) ? data.rows : [] const metrics = empty() @@ -155,6 +157,14 @@ export async function POST(req: NextRequest) { // platforms write account+campaign rows themselves (via runAdapterSync); // legacy MMP platforms still use the inline upsert below. for (const auth of org.platformAuths) { + if (auth.platform === 'adjust') { + // App snapshots are not media spend or legacy account snapshots. + const configured = await prisma.platformAccount.count({ where: { orgId: org.id, platform: 'adjust', isActive: true, extra: { not: null } } }) + if (configured) { + syncResults.adjust = { apps: await syncConfiguredAdjust(org.id, startDate, endDate, true) } + continue + } + } const metrics = await syncOne(auth.platform, auth, startDate, endDate, today) if ('error' in metrics) { syncResults[auth.platform] = { error: metrics.error } diff --git a/src/app/api/platforms/accounts/route.ts b/src/app/api/platforms/accounts/route.ts index 393eca2..b08a1be 100644 --- a/src/app/api/platforms/accounts/route.ts +++ b/src/app/api/platforms/accounts/route.ts @@ -38,6 +38,7 @@ export async function POST(req: NextRequest) { const { org } = await requireAuthWithOrg() const data = await req.json() const platform = String(data.platform || '').trim() + if (platform === 'adjust') return NextResponse.json({ error: 'Use Adjust setup to manage app configurations' }, { status: 400 }) const accountId = String(data.accountId || '').trim() if (!platform || !accountId) { return NextResponse.json({ error: 'platform and accountId are required' }, { status: 400 }) @@ -100,11 +101,14 @@ export async function POST(req: NextRequest) { // a new primary via a subsequent POST; we don't auto-promote. export async function DELETE(req: NextRequest) { try { - const { org } = await requireAuthWithOrg() - const { platform, accountId } = await req.json() + const { org, role } = await requireAuthWithOrg() + const body = await req.json() + const platform = typeof body?.platform === 'string' ? body.platform.trim() : '' + const accountId = typeof body?.accountId === 'string' ? body.accountId.trim() : '' if (!platform || !accountId) { return NextResponse.json({ error: 'platform and accountId are required' }, { status: 400 }) } + if (platform === 'adjust' && !['owner', 'admin'].includes(role)) return NextResponse.json({ error: 'Workspace admin access required' }, { status: 403 }) await prisma.platformAccount.deleteMany({ where: { orgId: org.id, platform, accountId }, }) diff --git a/src/app/api/platforms/route.ts b/src/app/api/platforms/route.ts index d1e1977..b13e81c 100644 --- a/src/app/api/platforms/route.ts +++ b/src/app/api/platforms/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/prisma' import { requireAuthWithOrg } from '@/lib/auth' +import { logAudit } from '@/lib/audit' export async function GET() { try { @@ -14,8 +15,8 @@ export async function GET() { platform: a.platform, accountId: a.accountId, appId: a.appId, - apiKey: a.apiKey, - extra: a.extra, + hasApiKey: !!a.apiKey, + extra: a.platform === 'adjust' ? null : a.extra, isActive: a.isActive, hasRefreshToken: !!a.refreshToken, hasAccessToken: !!a.accessToken, @@ -34,6 +35,7 @@ export async function POST(req: NextRequest) { const { user, org } = await requireAuthWithOrg() const data = await req.json() const platform = data.platform as string + if (platform === 'adjust') return NextResponse.json({ error: 'Use Adjust setup to manage this connection' }, { status: 400 }) // Check if record exists (scoped to org) const existing = await prisma.platformAuth.findUnique({ @@ -95,7 +97,7 @@ export async function POST(req: NextRequest) { return NextResponse.json({ id: auth.id, platform: auth.platform, isActive: auth.isActive, - accountId: auth.accountId, apiKey: auth.apiKey, appId: auth.appId, + accountId: auth.accountId, hasApiKey: !!auth.apiKey, appId: auth.appId, }) } catch (error) { const message = error instanceof Error ? error.message : 'Failed to save authorization' @@ -105,12 +107,16 @@ export async function POST(req: NextRequest) { export async function DELETE(req: NextRequest) { try { - const { org } = await requireAuthWithOrg() - const { platform } = await req.json() + const { org, user, role } = await requireAuthWithOrg() + const body = await req.json() + const platform = typeof body?.platform === 'string' ? body.platform.trim() : '' + if (!platform) return NextResponse.json({ error: 'platform is required' }, { status: 400 }) + if (platform === 'adjust' && !['owner', 'admin'].includes(role)) return NextResponse.json({ error: 'Workspace admin access required' }, { status: 403 }) await prisma.$transaction([ prisma.platformAccount.deleteMany({ where: { orgId: org.id, platform } }), prisma.platformAuth.deleteMany({ where: { orgId: org.id, platform } }), ]) + await logAudit({ orgId: org.id, userId: user.id, action: 'platform.disconnect', targetType: 'platform', targetId: platform, req }) return NextResponse.json({ ok: true }) } catch { return NextResponse.json({ error: 'Failed to delete' }, { status: 500 }) diff --git a/src/app/api/reports/sync/route.ts b/src/app/api/reports/sync/route.ts index e8bd5ef..57dfe4e 100644 --- a/src/app/api/reports/sync/route.ts +++ b/src/app/api/reports/sync/route.ts @@ -3,6 +3,8 @@ import { prisma } from '@/lib/prisma' import { requireAuthWithOrg } from '@/lib/auth' import { AppsFlyerClient } from '@/lib/platforms/appsflyer' import { AdjustClient } from '@/lib/platforms/adjust' +import { openCredential } from '@/lib/platform-credential' +import { syncConfiguredAdjust } from '@/lib/reports/adjust-service' import { AmazonAdsClient } from '@/lib/platforms/amazon' import { LinkedInAdsClient } from '@/lib/platforms/linkedin' import type { PlatformAuth } from '@/generated/prisma/client' @@ -177,7 +179,7 @@ async function syncAdjust( } const client = new AdjustClient({ - apiToken: auth.apiKey, + apiToken: openCredential(auth.apiKey, `${orgId}:adjust`), appToken: auth.appId, }) @@ -279,11 +281,12 @@ async function syncLinkedIn( // ---------------- Main handler ---------------- export async function POST() { - let user, org + let user, org, role try { const ctx = await requireAuthWithOrg() user = ctx.user org = ctx.org + role = ctx.role } catch { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -325,7 +328,14 @@ export async function POST() { results.appsflyer = await syncAppsFlyer(auth, org.id, user.id, startDate, endDate, today) break case 'adjust': - results.adjust = await syncAdjust(auth, org.id, user.id, startDate, endDate, today) + { + if (!['owner', 'admin'].includes(role)) { + results.adjust = { error: 'Workspace admin access required' } + break + } + const configured = await syncConfiguredAdjust(org.id, startDate, endDate) + results.adjust = configured.length ? { apps: configured } : await syncAdjust(auth, org.id, user.id, startDate, endDate, today) + } break case 'amazon': results.amazon = await syncAmazon(auth, org.id, user.id, startDate, endDate, today) diff --git a/src/components/adjust-setup.tsx b/src/components/adjust-setup.tsx new file mode 100644 index 0000000..7d987ad --- /dev/null +++ b/src/components/adjust-setup.tsx @@ -0,0 +1,389 @@ +'use client' +import { useEffect, useState } from 'react' +import Link from 'next/link' +import { ArrowLeft, Eye, Link2, RefreshCw, Save } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { api } from '@/lib/utils' +import type { AdjustPlan } from '@/lib/reports/adjust-data' +import type { AdjustCatalogItem, AdjustEvent } from '@/lib/platforms/adjust' +import { AdjustPlanForm } from './adjust/plan-form' +import { AdjustReportView, type Report } from './adjust/report-view' +import { Field, inputClass } from './adjust/fields' + +type SetupState = { + connected: boolean + canManage: boolean + plans: AdjustPlan[] + products: Array<{ id: string; name: string; platform: string }> +} +const dateBefore = (days: number) => + new Date(Date.now() - days * 86400000).toISOString().slice(0, 10) +async function request( + url: string, + method = 'GET', + body?: unknown, +): Promise { + const response = await fetch(api(url), { + method, + cache: 'no-store', + ...(body === undefined + ? {} + : { + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + }) + const data = await response.json() + if (!response.ok) throw new Error(data?.error || 'Request failed') + if ( + url.startsWith('/api/adjust/reports') && + data !== null && + (!data?.plan || !Array.isArray(data?.data?.rows) || !data?.data?.totals) + ) + throw new Error('Invalid report response') + return data +} + +export function AdjustSetup() { + const [state, setState] = useState(null) + const [apps, setApps] = useState([]) + const [events, setEvents] = useState([]) + const [token, setToken] = useState('') + const [plan, setPlan] = useState(null) + const [report, setReport] = useState(null) + const [startDate, setStart] = useState(() => dateBefore(7)) + const [endDate, setEnd] = useState(() => dateBefore(1)) + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + const [notice, setNotice] = useState('') + + useEffect(() => { + let active = true + request('/api/adjust') + .then((next) => { + if (!Array.isArray(next.plans) || !Array.isArray(next.products)) + throw new Error('Invalid setup response') + if (active) { + setState(next) + setApps( + next.plans.map((plan) => ({ + id: plan.appToken, + name: plan.appName, + })), + ) + } + }) + .catch((error) => { + if (active) setError(error.message) + }) + return () => { + active = false + } + }, []) + + async function run(work: () => Promise) { + setBusy(true) + setError('') + setNotice('') + try { + await work() + } catch (error) { + setError(error instanceof Error ? error.message : 'Request failed') + } finally { + setBusy(false) + } + } + + async function discover() { + const data = await request<{ apps: AdjustCatalogItem[] }>( + '/api/adjust/catalog', + ) + if (!Array.isArray(data.apps)) throw new Error('Invalid app catalog') + setApps(data.apps) + if (!data.apps.length) setNotice('此连接下没有可访问的应用') + } + + async function selectApp(appToken: string) { + setPlan(null) + setReport(null) + setEvents([]) + if (!appToken) return + const app = apps.find((app) => app.id === appToken)! + const saved = state?.plans.find((plan) => plan.appToken === appToken) + const next: AdjustPlan = saved || { + version: 1, + appToken, + appName: app.name, + productId: null, + currency: 'USD', + utcOffset: '+00:00', + attributionSource: 'first', + reattributed: 'false', + autoSync: false, + metricsConfirmed: false, + metrics: {}, + sourceRules: [], + } + // Saved data remains available even when the provider catalog is down. + setPlan(next) + if (saved) + setReport( + await request( + `/api/adjust/reports?appToken=${encodeURIComponent(appToken)}`, + ), + ) + const catalog = state?.canManage + ? await request<{ events: AdjustEvent[] }>( + `/api/adjust/catalog?appToken=${encodeURIComponent(appToken)}`, + ) + : { events: [] } + if (!Array.isArray(catalog.events)) throw new Error('Invalid event catalog') + setEvents(catalog.events) + } + + const saved = + !!plan && !!state?.plans.some((item) => item.appToken === plan.appToken) + const draftChanged = + !!report && JSON.stringify(plan) !== JSON.stringify(report.plan) + const canSave = + !!report && + !draftChanged && + report.startDate === startDate && + report.endDate === endDate + + return ( +
+
+
+ + + +

Adjust 数据连接

+
+ + {state + ? state.connected + ? '已连接' + : '未连接' + : error + ? '加载失败' + : '加载中'} + +
+ {error && ( +

+ {error} +

+ )} + {notice && ( +

+ {notice} +

+ )} + {state?.canManage && ( +
+
+ + setToken(event.target.value)} + disabled={busy} + /> + +
+ + + Adjust 授权文档 + +
+ )} + {state?.connected && ( + <> +
+
+ + + +
+ {state.canManage && ( + + )} +
+ {plan && ( + <> + +
+ + setStart(event.target.value)} + /> + + + setEnd(event.target.value)} + /> + + {state.canManage && ( + <> + + + {saved && ( + + )} + + )} + {busy && ( + + 处理中 + + )} +
+ + )} + {report && ( + + )} + + )} +
+ ) +} diff --git a/src/components/adjust/fields.tsx b/src/components/adjust/fields.tsx new file mode 100644 index 0000000..e49be1c --- /dev/null +++ b/src/components/adjust/fields.tsx @@ -0,0 +1,34 @@ +import type { ReactNode } from 'react' +import type { MetricSlot, Traffic } from '@/lib/reports/adjust-data' +export const trafficNames: Record = { + organic: '自然', + paid: '付费', + other: '其他已知', + unknown: '未知', +} +export const metricNames: Record = { + registration: '注册', + payer: '付费人数', + revenue: '收入', +} +export const inputClass = + 'w-full min-w-0 rounded border border-gray-300 bg-white px-3 py-2 text-sm' + +export function Field({ + htmlFor, + label, + children, +}: { + htmlFor: string + label: string + children: ReactNode +}) { + return ( +
+ + {children} +
+ ) +} diff --git a/src/components/adjust/plan-form.tsx b/src/components/adjust/plan-form.tsx new file mode 100644 index 0000000..c8181f8 --- /dev/null +++ b/src/components/adjust/plan-form.tsx @@ -0,0 +1,358 @@ +'use client' +import { Button } from '@/components/ui/button' +import { Plus, Trash2 } from 'lucide-react' +import { + ADJUST_PLATFORMS, + METRIC_SLOTS, + type AdjustPlan, + type MetricSlot, + type Traffic, +} from '@/lib/reports/adjust-data' +import type { AdjustEvent } from '@/lib/platforms/adjust' +import { Field, inputClass, metricNames, trafficNames } from './fields' + +export function AdjustPlanForm({ + plan, + setPlan, + events, + products, + canManage, + busy, +}: { + plan: AdjustPlan + setPlan: (plan: AdjustPlan) => void + events: AdjustEvent[] + products: Array<{ id: string; name: string; platform: string }> + canManage: boolean + busy: boolean +}) { + function setMetric(slot: MetricSlot, id: string) { + const metrics = { ...plan.metrics } + const event = events.find((event) => event.id === id) + if (!event) delete metrics[slot] + else + metrics[slot] = { + id, + name: event.name, + kind: + slot === 'revenue' ? 'money' : slot === 'payer' ? 'users' : 'events', + } + setPlan({ ...plan, metrics, metricsConfirmed: false }) + } + + return ( +
+ 应用配置 +
+ + + + + + + + + setPlan({ ...plan, utcOffset: event.target.value }) + } + placeholder="+00:00" + /> + + + + +
+
+ {METRIC_SLOTS.map((slot) => ( +
+ + + + {slot === 'registration' && plan.metrics.registration && ( + + + + )} + {plan.metrics[slot] && ( +

+ {events.find((event) => event.id === plan.metrics[slot]?.id) + ?.description || plan.metrics[slot]?.id} +

+ )} +
+ ))} +
+

+ 付费人数口径为区间去重用户,不是购买次数。当前报表为区间指标,不标注注册 + cohort 或 D7 ROAS。 +

+ {Object.keys(plan.metrics).length > 0 && ( + + )} +
+ + +
+
+
+

来源映射

+ +
+ {plan.sourceRules.map((rule, index) => ( +
+ + + setPlan({ + ...plan, + sourceRules: plan.sourceRules.map((rule, i) => + i === index + ? { ...rule, network: event.target.value } + : rule, + ), + }) + } + /> + + + + setPlan({ + ...plan, + sourceRules: plan.sourceRules.map((rule, i) => + i === index + ? { ...rule, partner: event.target.value } + : rule, + ), + }) + } + /> + + + + + + + + +
+ ))} +
+
+ ) +} diff --git a/src/components/adjust/report-view.tsx b/src/components/adjust/report-view.tsx new file mode 100644 index 0000000..290ebff --- /dev/null +++ b/src/components/adjust/report-view.tsx @@ -0,0 +1,237 @@ +'use client' +import { useState } from 'react' +import { Download } from 'lucide-react' +import { + METRIC_SLOTS, + type AdjustPlan, + type AdjustNormalizedReport, +} from '@/lib/reports/adjust-data' +import { Field, inputClass, metricNames, trafficNames } from './fields' +export type Report = { + plan: AdjustPlan + startDate: string + endDate: string + data: AdjustNormalizedReport & { providerWarningCount: number } + syncedAt?: string + stale?: boolean +} + +const valueText = (value: number | null) => + value === null + ? '--' + : value.toLocaleString(undefined, { maximumFractionDigits: 2 }) + +export function AdjustReportView({ + report, + draftChanged, +}: { + report: Report + draftChanged: boolean +}) { + const [traffic, setTraffic] = useState('all') + const [platform, setPlatform] = useState('all') + function exportCsv() { + if (!report) return + const fields = [ + 'network', + 'partner', + 'traffic', + 'platform', + 'os', + 'accountId', + 'campaignId', + 'campaignName', + 'installs', + 'registration', + 'payer', + 'revenue', + 'reason', + ] as const + const quote = (value: unknown) => + `"${(typeof value === 'string' ? value.replace(/^[=+\-@\t\r\n]/, "'$&") : String(value ?? '')).replaceAll('"', '""')}"` + const metadata = { + appToken: report.plan.appToken, + startDate: report.startDate, + endDate: report.endDate, + currency: report.plan.currency, + utcOffset: report.plan.utcOffset, + attributionSource: report.plan.attributionSource, + registrationMetric: report.plan.metrics.registration?.id ?? '', + registrationKind: report.plan.metrics.registration?.kind ?? '', + payerMetric: report.plan.metrics.payer?.id ?? '', + revenueMetric: report.plan.metrics.revenue?.id ?? '', + } + const csv = [ + [...Object.keys(metadata), ...fields].join(','), + ...visibleRows.map((row) => + [...Object.values(metadata), ...fields.map((field) => row[field])] + .map(quote) + .join(','), + ), + ].join('\n') + const url = URL.createObjectURL( + new Blob(['\uFEFF', csv], { type: 'text/csv;charset=utf-8' }), + ) + const link = document.createElement('a') + link.href = url + link.download = `adjust-${report.plan.appToken}-${report.startDate}-${report.endDate}.csv` + link.click() + URL.revokeObjectURL(url) + } + + const visibleRows = + report?.data.rows.filter( + (row) => + (traffic === 'all' || row.traffic === traffic) && + (platform === 'all' || (row.platform ?? 'unknown') === platform), + ) ?? [] + + return ( +
+
+
+

+ {report.plan.appName} / 报表 +

+

+ {report.startDate} ~ {report.endDate} · {report.plan.currency} · UTC + {report.plan.utcOffset} ·{' '} + {report.syncedAt + ? `更新于 ${new Date(report.syncedAt).toLocaleString()}` + : '未保存的预览'} +

+
+ +
+ {(draftChanged || report.stale) && ( +

+ 配置已变化,当前显示的是上次查询口径 +

+ )} + {report.data.providerWarningCount > 0 && ( +

+ Adjust 返回 {report.data.providerWarningCount}{' '} + 项数据警告,报表可能不完整 +

+ )} +

+ 全应用 / 当前区间。总量由 Adjust + 独立查询,不累加明细去重人数;不含媒体花费。 +

+
+ {(['installs', ...METRIC_SLOTS] as const).map((slot) => ( +
+
+ {slot === 'installs' + ? '安装' + : `${metricNames[slot]}${slot === 'registration' ? (report.plan.metrics.registration?.kind === 'users' ? '人数' : '次数') : ''}`} +
+
+ {valueText(report.data.totals[slot])} +
+
+ ))} +
+
+ + + + + + + + {visibleRows.length} 条明细 + +
+
+ + + + + + + + + + + + + + + + + {visibleRows.map((row, i) => ( + + + + + + + + + + + + + ))} + +
流量平台原始来源 / PartnerOS账户 / 系列 ID安装 + 注册 + {report.plan.metrics.registration?.kind === 'users' + ? '人数' + : '次数'} + 付费人数收入分类依据
{trafficNames[row.traffic]}{row.platform || '--'} + {row.network || '--'} +
+ {row.partner || '--'} +
+
{row.os || '--'} + {row.campaignName || '--'} +
+ {row.accountId || '--'} / {row.campaignId || '--'} +
+
{valueText(row.installs)}{valueText(row.registration)}{valueText(row.payer)}{valueText(row.revenue)}{row.reason}
+
+ {!visibleRows.length && ( +

+ 当前筛选下没有数据 +

+ )} +
+ ) +} diff --git a/src/lib/auth-token.test.ts b/src/lib/auth-token.test.ts new file mode 100644 index 0000000..f3488c3 --- /dev/null +++ b/src/lib/auth-token.test.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { signSessionToken, verifySessionToken } from './auth-token' + +afterEach(() => { vi.unstubAllEnvs(); vi.useRealTimers() }) + +describe('signed session boundary', () => { + it('rejects raw user IDs, empty and malformed cookies', () => { + vi.stubEnv('AUTH_TOKEN_SECRET', 'fixture-session-secret') + for (const token of ['', 'cmexistinguser123456789012', 'not.a.token', 'broken.signature']) { + expect(verifySessionToken(token)).toBeNull() + } + }) + it('preserves signed sessions and pre-session-table signed cookies', () => { + vi.stubEnv('AUTH_TOKEN_SECRET', 'fixture-session-secret') + expect(verifySessionToken(signSessionToken('fixture-user', 'fixture-session'))).toMatchObject({ uid: 'fixture-user', sid: 'fixture-session' }) + expect(verifySessionToken(signSessionToken('fixture-user'))).toMatchObject({ uid: 'fixture-user' }) + }) + it('rejects tampering and expiration', () => { + vi.stubEnv('AUTH_TOKEN_SECRET', 'fixture-session-secret') + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-09-24T00:00:00Z')) + const token = signSessionToken('fixture-user', 'fixture-session', 60) + const [, signature] = token.split('.') + const otherPayload = Buffer.from(JSON.stringify({ uid: 'other-user', exp: 9999999999 })).toString('base64url') + expect(verifySessionToken(`${otherPayload}.${signature}`)).toBeNull() + vi.advanceTimersByTime(61_000) + expect(verifySessionToken(token)).toBeNull() + }) + it('fails closed with no signing secret', () => { + vi.stubEnv('AUTH_TOKEN_SECRET', '') + vi.stubEnv('NEXTAUTH_SECRET', '') + expect(() => signSessionToken('fixture-user')).toThrow('AUTH_TOKEN_SECRET') + expect(verifySessionToken('payload.signature')).toBeNull() + }) +}) diff --git a/src/lib/auth-token.ts b/src/lib/auth-token.ts new file mode 100644 index 0000000..48e6f19 --- /dev/null +++ b/src/lib/auth-token.ts @@ -0,0 +1,65 @@ +import crypto from 'crypto' + +function getSecret(): string { + const secret = process.env.AUTH_TOKEN_SECRET || process.env.NEXTAUTH_SECRET || '' + if (!secret) throw new Error('AUTH_TOKEN_SECRET (or NEXTAUTH_SECRET) is not set. Refusing unsigned sessions.') + return secret +} + +// HMAC-signed session token: base64url(payload).base64url(sig) +// payload includes a session id (sid) so we can revoke individual +// sessions server-side. Older tokens without sid (pre-v28) are still +// accepted (stateless) to avoid logging everyone out on deploy. +type SessionPayload = { uid: string; sid?: string; iat: number; exp: number } + +function b64urlEncode(buf: Buffer): string { + return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +function b64urlDecode(str: string): Buffer { + const pad = str.length % 4 === 0 ? '' : '='.repeat(4 - (str.length % 4)) + return Buffer.from(str.replace(/-/g, '+').replace(/_/g, '/') + pad, 'base64') +} + +function sign(data: string): string { + return b64urlEncode( + crypto.createHmac('sha256', getSecret()).update(data).digest() + ) +} + +export function signSessionToken( + userId: string, + sessionId?: string, + ttlSeconds = 60 * 60 * 24 * 30 +): string { + const now = Math.floor(Date.now() / 1000) + const payload: SessionPayload = { + uid: userId, + ...(sessionId ? { sid: sessionId } : {}), + iat: now, + exp: now + ttlSeconds, + } + const payloadStr = b64urlEncode(Buffer.from(JSON.stringify(payload))) + const sig = sign(payloadStr) + return `${payloadStr}.${sig}` +} + +export function verifySessionToken(token: string): SessionPayload | null { + try { + const parts = token.split('.') + if (parts.length !== 2) return null + const [payloadStr, sig] = parts + const expected = sign(payloadStr) + // constant-time compare + const a = Buffer.from(sig) + const b = Buffer.from(expected) + if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null + + const payload = JSON.parse(b64urlDecode(payloadStr).toString('utf8')) as SessionPayload + if (!payload.uid || !payload.exp) return null + if (payload.exp * 1000 < Date.now()) return null + return payload + } catch { + return null + } +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 4467314..c646a05 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -2,24 +2,12 @@ import { cookies } from 'next/headers' import { prisma } from './prisma' import crypto from 'crypto' import bcrypt from 'bcrypt' +import { signSessionToken, verifySessionToken } from './auth-token' +export { signSessionToken, verifySessionToken } from './auth-token' // bcrypt cost factor — 12 is a reasonable default for web apps in 2026 const BCRYPT_ROUNDS = 12 -const TOKEN_SECRET = - process.env.AUTH_TOKEN_SECRET || - process.env.NEXTAUTH_SECRET || - '' - -function getSecret(): string { - if (!TOKEN_SECRET) { - throw new Error( - 'AUTH_TOKEN_SECRET (or NEXTAUTH_SECRET) is not set. Refusing to sign/verify tokens with an empty secret.' - ) - } - return TOKEN_SECRET -} - /** * Hash a password with bcrypt. Sync signature for backward compatibility * with existing call sites that don't await, but delegates to bcrypt @@ -82,44 +70,6 @@ export function generateToken(): string { return crypto.randomBytes(32).toString('hex') } -// HMAC-signed session token: base64url(payload).base64url(sig) -// payload includes a session id (sid) so we can revoke individual -// sessions server-side. Older tokens without sid (pre-v28) are still -// accepted (stateless) to avoid logging everyone out on deploy. -type SessionPayload = { uid: string; sid?: string; iat: number; exp: number } - -function b64urlEncode(buf: Buffer): string { - return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') -} - -function b64urlDecode(str: string): Buffer { - const pad = str.length % 4 === 0 ? '' : '='.repeat(4 - (str.length % 4)) - return Buffer.from(str.replace(/-/g, '+').replace(/_/g, '/') + pad, 'base64') -} - -function sign(data: string): string { - return b64urlEncode( - crypto.createHmac('sha256', getSecret()).update(data).digest() - ) -} - -export function signSessionToken( - userId: string, - sessionId?: string, - ttlSeconds = 60 * 60 * 24 * 30 -): string { - const now = Math.floor(Date.now() / 1000) - const payload: SessionPayload = { - uid: userId, - ...(sessionId ? { sid: sessionId } : {}), - iat: now, - exp: now + ttlSeconds, - } - const payloadStr = b64urlEncode(Buffer.from(JSON.stringify(payload))) - const sig = sign(payloadStr) - return `${payloadStr}.${sig}` -} - /** * Create a Session row and return a signed token referencing it. This * is the canonical login flow — every new cookie should come from here @@ -151,26 +101,6 @@ export async function createSession(opts: { return token } -export function verifySessionToken(token: string): SessionPayload | null { - try { - const parts = token.split('.') - if (parts.length !== 2) return null - const [payloadStr, sig] = parts - const expected = sign(payloadStr) - // constant-time compare - const a = Buffer.from(sig) - const b = Buffer.from(expected) - if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null - - const payload = JSON.parse(b64urlDecode(payloadStr).toString('utf8')) as SessionPayload - if (!payload.uid || !payload.exp) return null - if (payload.exp * 1000 < Date.now()) return null - return payload - } catch { - return null - } -} - export async function getCurrentUser() { const cookieStore = await cookies() const token = cookieStore.get('auth_token')?.value @@ -203,13 +133,6 @@ export async function getCurrentUser() { return prisma.user.findUnique({ where: { id: payload.uid } }) } - // Legacy path: pre-signed-token cookies stored raw user ID. - // Accept only if it matches an existing user — migrated out on next login. - if (/^[a-z0-9]{20,}$/i.test(token)) { - const user = await prisma.user.findUnique({ where: { id: token } }) - return user - } - return null } diff --git a/src/lib/platform-credential.test.ts b/src/lib/platform-credential.test.ts new file mode 100644 index 0000000..6edbba3 --- /dev/null +++ b/src/lib/platform-credential.test.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { sealCredential, openCredential } from './platform-credential' + +afterEach(() => vi.unstubAllEnvs()) +describe('platform credential storage', () => { + it('encrypts with random nonces, authenticates scope and rejects tampering', () => { + vi.stubEnv( + 'PLATFORM_CREDENTIAL_KEY', + Buffer.alloc(32, 7).toString('base64'), + ) + const encrypted = sealCredential('fixture-secret', 'org-a:adjust') + expect(encrypted).not.toContain('fixture-secret') + expect(encrypted).not.toBe(sealCredential('fixture-secret', 'org-a:adjust')) + expect(openCredential(encrypted, 'org-a:adjust')).toBe('fixture-secret') + expect(() => openCredential(encrypted, 'org-b:adjust')).toThrow() + expect(() => + openCredential(encrypted.slice(0, -4) + 'AAAA', 'org-a:adjust'), + ).toThrow() + }) + it('fails closed without a dedicated valid encryption key', () => { + vi.stubEnv('PLATFORM_CREDENTIAL_KEY', '') + expect(() => sealCredential('fixture', 'org-a:adjust')).toThrow( + 'PLATFORM_CREDENTIAL_KEY', + ) + vi.stubEnv('PLATFORM_CREDENTIAL_KEY', 'short') + expect(() => sealCredential('fixture', 'org-a:adjust')).toThrow( + 'PLATFORM_CREDENTIAL_KEY', + ) + }) + it('reads existing plaintext connections only for compatibility; new writes are encrypted', () => { + expect(openCredential('legacy-fixture', 'org-a:adjust')).toBe( + 'legacy-fixture', + ) + }) +}) diff --git a/src/lib/platform-credential.ts b/src/lib/platform-credential.ts new file mode 100644 index 0000000..ed809b1 --- /dev/null +++ b/src/lib/platform-credential.ts @@ -0,0 +1,46 @@ +import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto' + +const PREFIX = 'enc:v1:' +function key() { + const encoded = process.env.PLATFORM_CREDENTIAL_KEY || '' + const value = Buffer.from(encoded, 'base64') + if (value.length !== 32 || value.toString('base64') !== encoded) + throw new Error( + 'Configure PLATFORM_CREDENTIAL_KEY with a base64-encoded 32-byte key', + ) + return value +} + +export function sealCredential(value: string, scope: string): string { + const nonce = randomBytes(12) + const cipher = createCipheriv('aes-256-gcm', key(), nonce) + cipher.setAAD(Buffer.from(scope)) + const ciphertext = Buffer.concat([ + cipher.update(value, 'utf8'), + cipher.final(), + ]) + return ( + PREFIX + + Buffer.concat([nonce, cipher.getAuthTag(), ciphertext]).toString('base64') + ) +} + +export function openCredential(value: string, scope: string): string { + // Existing connections are migrated when their token is replaced in setup. + if (!value.startsWith('enc:')) return value + if (!value.startsWith(PREFIX)) + throw new Error('Unsupported credential format') + const payload = Buffer.from(value.slice(PREFIX.length), 'base64') + if (payload.length < 29) throw new Error('Invalid encrypted credential') + const decipher = createDecipheriv( + 'aes-256-gcm', + key(), + payload.subarray(0, 12), + ) + decipher.setAAD(Buffer.from(scope)) + decipher.setAuthTag(payload.subarray(12, 28)) + return Buffer.concat([ + decipher.update(payload.subarray(28)), + decipher.final(), + ]).toString('utf8') +} diff --git a/src/lib/platforms/adjust.test.ts b/src/lib/platforms/adjust.test.ts new file mode 100644 index 0000000..06a2854 --- /dev/null +++ b/src/lib/platforms/adjust.test.ts @@ -0,0 +1,195 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AdjustClient } from './adjust' +import { parseAdjustPlan } from '@/lib/reports/adjust-data' + +const fetchMock = vi.fn() +const client = new AdjustClient({ + apiToken: 'fixture-not-a-key', + appToken: 'fixture-app', +}) + +beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) +}) +afterEach(() => vi.unstubAllGlobals()) + +describe('Adjust report boundary (offline)', () => { + it('discovers apps and filters events to the selected app, excluding SKAN', async () => { + fetchMock.mockResolvedValueOnce( + Response.json({ apps: [{ id: 'fixture-app', name: 'Luddi' }] }), + ) + expect(await client.listApps()).toEqual([ + { id: 'fixture-app', name: 'Luddi' }, + ]) + expect(String(fetchMock.mock.calls[0][0])).toContain( + 'filters_data?required_filters=apps', + ) + fetchMock.mockResolvedValueOnce( + Response.json([ + { + id: 'signup', + name: 'Signup', + app_token: ['fixture-app'], + is_skad_event: false, + }, + { id: 'wrong', name: 'Other app', app_token: ['another-app'] }, + { + id: 'skan', + name: 'SKAN', + app_token: ['fixture-app'], + is_skad_event: true, + }, + ]), + ) + expect((await client.listEvents()).map((event) => event.id)).toEqual([ + 'signup', + ]) + expect( + new URL(String(fetchMock.mock.calls[1][0])).searchParams.get( + 'app_token__in', + ), + ).toBe('fixture-app') + }) + + it('does not follow redirects with the API credential', async () => { + fetchMock.mockResolvedValue( + new Response(null, { + status: 302, + headers: { location: 'https://example.test' }, + }), + ) + await expect(client.getReport('2026-09-01', '2026-09-02')).rejects.toThrow( + 'HTTP 302', + ) + expect(fetchMock.mock.calls[0][1].redirect).toBe('error') + }) + + it('pins app, timezone, currency and attribution while querying whole-window totals separately', async () => { + const plan = parseAdjustPlan({ + version: 1, + appToken: 'fixture-app', + appName: 'Luddi', + productId: null, + currency: 'USD', + utcOffset: '+08:00', + attributionSource: 'first', + reattributed: 'false', + autoSync: false, + metricsConfirmed: true, + metrics: { + registration: { id: 'luddi_signup', name: 'Signup', kind: 'events' }, + }, + sourceRules: [], + }) + fetchMock.mockResolvedValueOnce( + Response.json({ + rows: [ + { + app_token: 'fixture-app', + network: 'Organic', + installs: '10', + luddi_signup: '4', + }, + ], + }), + ) + fetchMock.mockResolvedValueOnce( + Response.json({ + rows: [{ app_token: 'fixture-app', installs: '10', luddi_signup: '4' }], + }), + ) + const result = await client.getMappedReport( + plan, + '2026-09-01', + '2026-09-07', + ) + expect(result.totals.registration).toBe(4) + const queries = fetchMock.mock.calls.map( + (call) => new URL(String(call[0])).searchParams, + ) + expect(queries).toHaveLength(2) + for (const query of queries) { + expect(query.get('app_token__in')).toBe('fixture-app') + expect(query.get('utc_offset')).toBe('+08:00') + expect(query.get('currency')).toBe('USD') + expect(query.get('metrics')).toBe('installs,luddi_signup') + expect(query.get('attribution_source')).toBe('first') + expect(query.get('reattributed')).toBe('false') + } + expect(queries[1].get('dimensions')).toBe('app_token') + }) + + it('uses the documented JSON endpoint and preserves rows, totals and warnings', async () => { + const report = { + rows: [{ day: '2026-09-01', installs: '10', revenue: '25.00' }], + totals: { installs: 10, revenue: 25 }, + warnings: ['fixture warning'], + } + fetchMock.mockResolvedValue(Response.json(report)) + + expect(await client.getReport('2026-09-01', '2026-09-02')).toEqual(report) + const [input, init] = fetchMock.mock.calls[0] + const url = new URL(String(input)) + expect(`${url.origin}${url.pathname}`).toBe( + 'https://automate.adjust.com/reports-service/report', + ) + expect(url.searchParams.get('app_token__in')).toBe('fixture-app') + expect(url.searchParams.get('date_period')).toBe('2026-09-01:2026-09-02') + expect(url.searchParams.get('dimensions')).toBe('day,app') + expect(init.headers.Authorization).toBe('Bearer fixture-not-a-key') + }) + + for (const method of ['getReport', 'getCohortReport'] as const) { + describe(method, () => { + it.each([401, 403, 429, 503])( + 'rejects HTTP %s instead of returning an empty success', + async (status) => { + // Some error bodies do not contain the legacy `error` property. + fetchMock.mockResolvedValue( + Response.json({ message: 'provider failure' }, { status }), + ) + await expect( + client[method]('2026-09-01', '2026-09-02'), + ).rejects.toThrow(`Adjust report request failed (HTTP ${status})`) + }, + ) + + it.each([ + null, + {}, + { rows: null }, + { rows: [null] }, + { rows: [], error: 'rejected' }, + ])('rejects an invalid success envelope: %j', async (body) => { + fetchMock.mockResolvedValue(Response.json(body)) + await expect( + client[method]('2026-09-01', '2026-09-02'), + ).rejects.toThrow('Invalid Adjust report response') + }) + + it('accepts a documented 204 as no rows', async () => { + fetchMock.mockResolvedValue(new Response(null, { status: 204 })) + await expect( + client[method]('2026-09-01', '2026-09-02'), + ).resolves.toEqual({ rows: [] }) + }) + + it('accepts a valid empty result', async () => { + fetchMock.mockResolvedValue(Response.json({ rows: [] })) + await expect( + client[method]('2026-09-01', '2026-09-02'), + ).resolves.toEqual({ rows: [] }) + }) + + it('rejects non-JSON success without exposing the response body', async () => { + fetchMock.mockResolvedValue( + new Response('fixture provider error'), + ) + await expect( + client[method]('2026-09-01', '2026-09-02'), + ).rejects.toThrow('Invalid Adjust report response') + }) + }) + } +}) diff --git a/src/lib/platforms/adjust.ts b/src/lib/platforms/adjust.ts index 0f7dfb3..ea6fe34 100644 --- a/src/lib/platforms/adjust.ts +++ b/src/lib/platforms/adjust.ts @@ -1,16 +1,47 @@ -/** - * 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. - */ +import { + isRecord, + type AdjustPlan, + validateRange, + normalizeAdjustReport, +} from '@/lib/reports/adjust-data' + +// Aggregate reports remain separate from user-level ConversionEvent ingestion. export interface AdjustConfig { apiToken: string appToken: string } -const BASE_URL = 'https://dash.adjust.com' +export interface AdjustCatalogItem { + id: string + name: string +} +export interface AdjustEvent extends AdjustCatalogItem { + description: string + formatting: string + section: string +} + +function apiBase() { + const fixture = process.env.ADJUST_TEST_API_URL + if (!fixture) return 'https://automate.adjust.com/reports-service' + const url = new URL(fixture) + if ( + process.env.NODE_ENV === 'production' || + url.protocol !== 'http:' || + url.hostname !== '127.0.0.1' || + url.username || + url.password || + url.search || + url.hash + ) + throw new Error('Invalid Adjust test endpoint') + return fixture.replace(/\/$/, '') +} + +interface AdjustReport { + rows: Record[] + [key: string]: unknown +} export class AdjustClient { private config: AdjustConfig @@ -19,29 +50,160 @@ export class AdjustClient { this.config = config } + async listApps(): Promise { + const data = await this.request('filters_data', { + required_filters: 'apps', + }) + if (!isRecord(data) || !Array.isArray(data.apps)) + throw new Error('Invalid Adjust app catalog') + return data.apps.map((item) => { + if ( + !isRecord(item) || + typeof item.id !== 'string' || + typeof item.name !== 'string' + ) + throw new Error('Invalid Adjust app catalog') + return { id: item.id, name: item.name } + }) + } + + async listEvents(): Promise { + const data = await this.request('events', { + app_token__in: this.config.appToken, + tokens_mapping: 'true', + }) + if (!Array.isArray(data)) throw new Error('Invalid Adjust event catalog') + const events: AdjustEvent[] = [] + for (const event of data) { + if ( + !isRecord(event) || + typeof event.id !== 'string' || + typeof event.name !== 'string' || + !Array.isArray(event.app_token) + ) + throw new Error('Invalid Adjust event catalog') + if ( + !event.app_token.includes(this.config.appToken) || + event.is_skad_event === true + ) + continue + events.push({ + id: event.id, + name: event.name, + description: String(event.description ?? ''), + formatting: String(event.formatting ?? ''), + section: String(event.section ?? ''), + }) + } + return events + } + + async getMappedReport(plan: AdjustPlan, startDate: string, endDate: string) { + validateRange(startDate, endDate) + if (plan.appToken !== this.config.appToken) + throw new Error('Adjust app configuration mismatch') + const parameters = { + metrics: [ + ...new Set([ + 'installs', + ...Object.values(plan.metrics).map((metric) => metric.id), + ]), + ].join(','), + currency: plan.currency, + utc_offset: plan.utcOffset, + attribution_source: plan.attributionSource, + reattributed: plan.reattributed, + sandbox: 'false', + format_dates: 'false', + } + const report = await this.requestReport(startDate, endDate, { + ...parameters, + dimensions: + 'app_token,os_name,partner,network,ad_account_id,campaign_id_network,campaign_network', + }) + // Query whole-window unique users separately; dimension rows are not additive. + const total = await this.requestReport(startDate, endDate, { + ...parameters, + dimensions: 'app_token', + }) + if (report.pagination || total.pagination) + throw new Error('Adjust report is paginated; narrow the reporting window') + return { + ...normalizeAdjustReport(report.rows, total.rows, plan), + providerWarningCount: [report, total].reduce( + (n, response) => + n + (Array.isArray(response.warnings) ? response.warnings.length : 0), + 0, + ), + } + } + async getReport(startDate: string, endDate: string) { - const response = await fetch( - `${BASE_URL}/control-center/reports-service/report?app_token__in=${this.config.appToken}&date_period=${startDate}:${endDate}&dimensions=day,app&metrics=installs,clicks,impressions,sessions,revenue,cost`, - { - headers: { - 'Authorization': `Bearer ${this.config.apiToken}`, - 'Accept': 'application/json', - }, - } - ) - return response.json() + return this.requestReport(startDate, endDate, { + dimensions: 'day,app', + metrics: 'installs,clicks,impressions,sessions,revenue,cost', + }) } async getCohortReport(startDate: string, endDate: string) { - const response = await fetch( - `${BASE_URL}/control-center/reports-service/report?app_token__in=${this.config.appToken}&date_period=${startDate}:${endDate}&dimensions=day&metrics=installs,retained_users,paying_users,revenue&cohort_maturity=immature`, - { - headers: { - 'Authorization': `Bearer ${this.config.apiToken}`, - 'Accept': 'application/json', - }, - } - ) - return response.json() + return this.requestReport(startDate, endDate, { + dimensions: 'day', + metrics: 'installs,retained_users,paying_users,revenue', + cohort_maturity: 'immature', + }) + } + + private async requestReport( + startDate: string, + endDate: string, + parameters: Record, + ): Promise { + const data = await this.request('report', { + app_token__in: this.config.appToken, + date_period: `${startDate}:${endDate}`, + ...parameters, + }) + if ( + !isRecord(data) || + data.error || + !Array.isArray(data.rows) || + !data.rows.every(isRecord) + ) { + throw new Error('Invalid Adjust report response') + } + return { ...data, rows: data.rows } + } + + private async request( + endpoint: 'report' | 'events' | 'filters_data', + parameters: Record, + ): Promise { + const query = new URLSearchParams(parameters) + const response = await fetch(`${apiBase()}/${endpoint}?${query}`, { + headers: { + Authorization: `Bearer ${this.config.apiToken}`, + Accept: 'application/json', + }, + cache: 'no-store', + redirect: 'error', + signal: AbortSignal.timeout(30_000), + }) + if (!response.ok) { + throw new Error(`Adjust report request failed (HTTP ${response.status})`) + } + if (response.status === 204) + return endpoint === 'report' + ? { rows: [] } + : endpoint === 'events' + ? [] + : { apps: [] } + + let data: unknown + try { + data = await response.json() + } catch { + throw new Error('Invalid Adjust report response') + } + return data } } diff --git a/src/lib/reports/adjust-data.test.ts b/src/lib/reports/adjust-data.test.ts new file mode 100644 index 0000000..f6e9b9d --- /dev/null +++ b/src/lib/reports/adjust-data.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, it } from "vitest"; +import { + classifyAdjustSource, + normalizeAdjustReport, + parseAdjustPlan, + validateRange, + completedAdjustRange, +} from "./adjust-data"; + +const plan = { + version: 1, + appToken: "luddi-fixture", + appName: "Luddi", + productId: null, + currency: "USD", + utcOffset: "+00:00", + attributionSource: "first", + reattributed: "false", + autoSync: false, + metricsConfirmed: true, + metrics: { + registration: { id: "luddi_signup", name: "Signup", kind: "events" }, + }, + sourceRules: [], +}; + +describe("Adjust source classification", () => { + it("keeps explicit organic, recognized paid and unknown separate", () => { + expect( + classifyAdjustSource({ network: "Organic", partner: "Organic" }), + ).toMatchObject({ traffic: "organic", platform: null }); + expect( + classifyAdjustSource({ + network: "Facebook Installs", + partner: "facebook", + }), + ).toMatchObject({ traffic: "paid", platform: "meta" }); + expect( + classifyAdjustSource({ + network: "Apple Search Ads", + partner: "apple_search_ads", + }), + ).toMatchObject({ traffic: "paid", platform: "asa" }); + expect( + classifyAdjustSource({ + network: "my newsletter", + campaign_network: "Google campaign", + }), + ).toMatchObject({ traffic: "unknown", platform: null }); + expect(classifyAdjustSource({})).toMatchObject({ + traffic: "unknown", + platform: null, + }); + }); + it("does not classify conflicts as organic or infer platform from a campaign name", () => { + expect( + classifyAdjustSource({ network: "Organic", partner: "facebook" }), + ).toMatchObject({ traffic: "unknown", reason: "conflict" }); + expect( + classifyAdjustSource({ + network: "not-google", + campaign_network: "Google Ads", + }).platform, + ).toBeNull(); + }); + it("applies exact app-specific source mappings", () => { + const rules = [ + { + network: "Newsletter", + partner: "", + traffic: "other" as const, + platform: null, + }, + ]; + expect(classifyAdjustSource({ network: "Newsletter" }, rules).traffic).toBe( + "other", + ); + expect( + classifyAdjustSource({ network: "Newsletter affiliate" }, rules).traffic, + ).toBe("unknown"); + }); +}); + +describe("Adjust configuration and report contract", () => { + it("uses complete days in each app reporting timezone for automatic sync", () => { + const now = new Date("2026-09-24T02:00:00Z"); + expect(completedAdjustRange("-07:00", now)).toEqual({ + startDate: "2026-09-16", + endDate: "2026-09-22", + }); + expect(completedAdjustRange("+08:00", now)).toEqual({ + startDate: "2026-09-17", + endDate: "2026-09-23", + }); + }); + it("requires explicit confirmation of event count versus unique-user semantics", () => { + expect(() => parseAdjustPlan({ ...plan, metricsConfirmed: false })).toThrow( + "Confirm", + ); + }); + it("validates product-specific event mappings without inventing defaults", () => { + const parsed = parseAdjustPlan(plan); + expect(parsed.metrics.registration?.id).toBe("luddi_signup"); + expect(parsed.metrics.payer).toBeUndefined(); + expect( + parseAdjustPlan({ ...plan, appToken: "cuddler-fixture", metrics: {} }) + .metrics, + ).toEqual({}); + }); + it("rejects injected metrics, invalid currency, offsets and ambiguous mappings", () => { + for (const changed of [ + { currency: "US" }, + { utcOffset: "+99:00" }, + { + metrics: { + registration: { id: "signup,cost", kind: "events", name: "bad" }, + }, + }, + { metrics: { payer: { id: "purchase", kind: "events", name: "bad" } } }, + { + sourceRules: [ + { network: "X", partner: "", traffic: "organic", platform: "google" }, + ], + }, + ]) + expect(() => parseAdjustPlan({ ...plan, ...changed })).toThrow(); + }); + it("rejects invalid, reversed and excessive date windows", () => { + for (const dates of [ + ["2026-02-30", "2026-03-01"], + ["2026-09-02", "2026-09-01"], + ["2020-01-01", "2026-01-01"], + ]) { + expect(() => validateRange(dates[0], dates[1])).toThrow(); + } + expect(validateRange("2026-09-01", "2026-09-07")).toEqual({ + startDate: "2026-09-01", + endDate: "2026-09-07", + }); + }); + it("retains raw attribution and IDs, excludes media cost and distinguishes missing from zero", () => { + const result = normalizeAdjustReport( + [ + { + app_token: "luddi-fixture", + network: "Organic", + installs: "4", + luddi_signup: "0", + cost: "99", + }, + { + app_token: "luddi-fixture", + partner: "facebook", + network: "Facebook Installs", + campaign_id_network: "campaign-1", + installs: "3", + }, + ], + [{ app_token: "luddi-fixture", installs: "7", luddi_signup: "0" }], + parseAdjustPlan(plan), + ); + expect(result.rows[0]).toMatchObject({ + traffic: "organic", + installs: 4, + registration: 0, + payer: null, + revenue: null, + }); + expect(result.rows[1]).toMatchObject({ + platform: "meta", + campaignId: "campaign-1", + registration: null, + }); + expect(result.rows[0]).not.toHaveProperty("cost"); + expect(result.rows[1].network).toBe("Facebook Installs"); + }); + it("uses a separately queried app total, never summed unique users", () => { + const config = parseAdjustPlan({ + ...plan, + metrics: { + payer: { id: "unique_payers", name: "Payers", kind: "users" }, + }, + }); + const result = normalizeAdjustReport( + [ + { app_token: config.appToken, network: "A", unique_payers: "2" }, + { app_token: config.appToken, network: "B", unique_payers: "2" }, + ], + [{ app_token: config.appToken, unique_payers: "3" }], + config, + ); + expect(result.totals.payer).toBe(3); + }); + it("rejects another app, duplicate grain and malformed numeric values", () => { + const config = parseAdjustPlan(plan); + const row = { app_token: plan.appToken, network: "Organic", installs: "4" }; + expect(() => + normalizeAdjustReport( + [{ ...row, app_token: "cuddler-fixture" }], + [], + config, + ), + ).toThrow(); + expect(() => normalizeAdjustReport([row, row], [], config)).toThrow( + "Duplicate", + ); + expect(() => + normalizeAdjustReport([{ ...row, installs: "garbage" }], [], config), + ).toThrow(); + }); +}); diff --git a/src/lib/reports/adjust-data.ts b/src/lib/reports/adjust-data.ts new file mode 100644 index 0000000..1b98e80 --- /dev/null +++ b/src/lib/reports/adjust-data.ts @@ -0,0 +1,314 @@ +export type Traffic = "organic" | "paid" | "other" | "unknown"; +export type MetricSlot = "registration" | "payer" | "revenue"; +export type AdjustMetric = { + id: string; + name: string; + kind: "events" | "users" | "money"; +}; +export type SourceRule = { + network: string; + partner: string; + traffic: Traffic; + platform: string | null; +}; +export type AdjustPlan = { + version: 1; + appToken: string; + appName: string; + productId: string | null; + currency: string; + utcOffset: string; + attributionSource: "first" | "dynamic"; + reattributed: "all" | "false"; + autoSync: boolean; + metricsConfirmed: boolean; + metrics: Partial>; + sourceRules: SourceRule[]; +}; +export const ADJUST_PLATFORMS = [ + "google", + "meta", + "tiktok", + "asa", + "applovin", + "unity", + "snapchat", + "amazon", + "other", +] as const; +export const METRIC_SLOTS: MetricSlot[] = ["registration", "payer", "revenue"]; +export const isRecord = (value: unknown): value is Record => + value !== null && typeof value === "object" && !Array.isArray(value); +const text = (value: unknown) => + typeof value === "string" ? value.trim() : ""; +const canonical = (value: unknown) => + text(value) + .toLowerCase() + .replace(/[ _-]+/g, " "); +const validId = (value: string) => /^[a-zA-Z0-9_.-]{1,160}$/.test(value); + +export function parseAdjustPlan(value: unknown): AdjustPlan { + if ( + !isRecord(value) || + value.version !== 1 || + !validId(text(value.appToken)) || + !text(value.appName) || + text(value.appName).length > 200 + ) + throw new Error("Invalid Adjust app configuration"); + if ( + !/^[A-Z]{3}$/.test(text(value.currency)) || + !/^[+-](0\d|1[0-4]):(00|15|30|45)$/.test(text(value.utcOffset)) + ) + throw new Error("Invalid reporting currency or UTC offset"); + if ( + !["first", "dynamic"].includes(text(value.attributionSource)) || + !["all", "false"].includes(text(value.reattributed)) || + typeof value.autoSync !== "boolean" + ) + throw new Error("Invalid attribution settings"); + if (value.productId !== null && !validId(text(value.productId))) + throw new Error("Invalid product"); + if ( + !isRecord(value.metrics) || + Object.keys(value.metrics).some( + (key) => !METRIC_SLOTS.includes(key as MetricSlot), + ) + ) + throw new Error("Invalid metric mapping"); + const metrics: AdjustPlan["metrics"] = {}; + if (Object.keys(value.metrics).length && value.metricsConfirmed !== true) + throw new Error( + "Confirm the selected metrics and their count, user and revenue definitions", + ); + for (const slot of METRIC_SLOTS) { + const metric = value.metrics[slot]; + if (metric === undefined) continue; + if ( + !isRecord(metric) || + !validId(text(metric.id)) || + !text(metric.name) || + text(metric.name).length > 200 || + !["events", "users", "money"].includes(text(metric.kind)) + ) + throw new Error("Invalid metric mapping"); + if (slot === "payer" && metric.kind !== "users") + throw new Error("Payers require a unique-user metric, not event counts"); + if ((slot === "revenue") !== (metric.kind === "money")) + throw new Error("Invalid revenue metric"); + metrics[slot] = { + id: text(metric.id), + name: text(metric.name), + kind: metric.kind as AdjustMetric["kind"], + }; + } + if (!Array.isArray(value.sourceRules) || value.sourceRules.length > 100) + throw new Error("Invalid source rules"); + const seen = new Set(); + const sourceRules = value.sourceRules.map((rule): SourceRule => { + if ( + !isRecord(rule) || + !text(rule.network) || + text(rule.network).length > 200 || + text(rule.partner).length > 200 || + !["organic", "paid", "other", "unknown"].includes(text(rule.traffic)) + ) + throw new Error("Invalid source rule"); + const platform = text(rule.platform) || null; + if (platform && !(ADJUST_PLATFORMS as readonly string[]).includes(platform)) + throw new Error("Invalid source platform"); + if (rule.traffic === "organic" && platform) + throw new Error("Organic traffic cannot have a paid platform"); + const key = JSON.stringify([ + canonical(rule.network), + canonical(rule.partner), + ]); + if (seen.has(key)) throw new Error("Duplicate source rule"); + seen.add(key); + return { + network: text(rule.network), + partner: text(rule.partner), + traffic: rule.traffic as Traffic, + platform, + }; + }); + return { + version: 1, + appToken: text(value.appToken), + appName: text(value.appName), + productId: text(value.productId) || null, + currency: text(value.currency), + utcOffset: text(value.utcOffset), + attributionSource: + value.attributionSource as AdjustPlan["attributionSource"], + reattributed: value.reattributed as AdjustPlan["reattributed"], + autoSync: value.autoSync, + metricsConfirmed: value.metricsConfirmed === true, + metrics, + sourceRules, + }; +} + +const platforms: Record = { + google: "google", + "google ads": "google", + "google adwords": "google", + "google adwords installs": "google", + facebook: "meta", + meta: "meta", + "facebook installs": "meta", + "instagram installs": "meta", + tiktok: "tiktok", + "tiktok installs": "tiktok", + "tiktok for business": "tiktok", + "apple search ads": "asa", + applovin: "applovin", + unity: "unity", + "unity ads": "unity", + snapchat: "snapchat", + "snapchat installs": "snapchat", + amazon: "amazon", +}; + +export function classifyAdjustSource( + row: Record, + rules: SourceRule[] = [], +) { + const network = canonical(row.network); + const partner = canonical(row.partner); + const rule = rules.find( + (rule) => + canonical(rule.network) === network && + canonical(rule.partner) === partner, + ); + if (rule) + return { + traffic: rule.traffic, + platform: rule.platform, + reason: "explicit_rule", + }; + const fromNetwork = platforms[network]; + const fromPartner = platforms[partner]; + if ( + (network === "organic" || partner === "organic") && + (fromNetwork || fromPartner) + ) + return { + traffic: "unknown" as Traffic, + platform: null, + reason: "conflict", + }; + if (fromNetwork && fromPartner && fromNetwork !== fromPartner) + return { + traffic: "unknown" as Traffic, + platform: null, + reason: "conflict", + }; + if ( + network === "organic" || + (partner === "organic" && (!network || network === "unknown")) + ) + return { + traffic: "organic" as Traffic, + platform: null, + reason: "provider_organic", + }; + if (fromPartner || fromNetwork) + return { + traffic: "paid" as Traffic, + platform: fromPartner || fromNetwork, + reason: "known_partner", + }; + return { traffic: "unknown" as Traffic, platform: null, reason: "unmapped" }; +} + +export function validateRange(startDate: string, endDate: string) { + const valid = (date: string) => + /^\d{4}-\d{2}-\d{2}$/.test(date) && + Number.isFinite(Date.parse(date)) && + new Date(date).toISOString().slice(0, 10) === date; + if ( + !valid(startDate) || + !valid(endDate) || + startDate > endDate || + (Date.parse(endDate) - Date.parse(startDate)) / 86400000 > 30 + ) + throw new Error("Select a valid reporting window of at most 31 days"); + return { startDate, endDate }; +} + +export function completedAdjustRange(utcOffset: string, now = new Date()) { + const offset = /^([+-])(0\d|1[0-4]):(00|15|30|45)$/.exec(utcOffset); + if (!offset) throw new Error("Invalid reporting UTC offset"); + const minutes = + (Number(offset[2]) * 60 + Number(offset[3])) * (offset[1] === "-" ? -1 : 1); + const localNow = now.getTime() + minutes * 60_000; + const dateBefore = (days: number) => + new Date(localNow - days * 86_400_000).toISOString().slice(0, 10); + return validateRange(dateBefore(7), dateBefore(1)); +} + +function metricValue( + row: Record, + id: string | undefined, + money = false, +): number | null { + if (!id || row[id] === undefined || row[id] === null || row[id] === "") + return null; + if (typeof row[id] !== "string" && typeof row[id] !== "number") + throw new Error("Invalid Adjust metric value"); + const value = Number(row[id]); + if ( + !Number.isFinite(value) || + (!money && (value < 0 || !Number.isSafeInteger(value))) + ) + throw new Error("Invalid Adjust metric value"); + return value; +} + +export function normalizeAdjustReport( + rows: Record[], + totals: Record[], + plan: AdjustPlan, +) { + if (rows.length > 5000 || totals.length > 1) + throw new Error("Adjust report exceeds the supported size"); + const readMetrics = (row: Record) => ({ + installs: metricValue(row, "installs"), + registration: metricValue(row, plan.metrics.registration?.id), + payer: metricValue(row, plan.metrics.payer?.id), + revenue: metricValue(row, plan.metrics.revenue?.id, true), + }); + const checkApp = (row: Record) => { + if (row.app_token !== plan.appToken) + throw new Error("Unexpected Adjust app in report"); + }; + const seen = new Set(); + const normalized = rows.map((row) => { + checkApp(row); + const dimensions = { + network: text(row.network), + partner: text(row.partner), + os: text(row.os_name), + accountId: text(row.ad_account_id), + campaignId: text(row.campaign_id_network), + campaignName: text(row.campaign_network), + }; + const key = JSON.stringify(dimensions); + if (seen.has(key)) throw new Error("Duplicate Adjust report grain"); + seen.add(key); + return { + ...dimensions, + ...classifyAdjustSource(row, plan.sourceRules), + ...readMetrics(row), + }; + }); + if (totals[0]) checkApp(totals[0]); + return { + rows: normalized, + totals: readMetrics(totals[0] ?? {}), + missingMetrics: METRIC_SLOTS.filter((slot) => !plan.metrics[slot]), + }; +} + +export type AdjustNormalizedReport = ReturnType; diff --git a/src/lib/reports/adjust-service.ts b/src/lib/reports/adjust-service.ts new file mode 100644 index 0000000..c3c6101 --- /dev/null +++ b/src/lib/reports/adjust-service.ts @@ -0,0 +1,273 @@ +import { createHash } from "node:crypto"; +import { prisma } from "@/lib/prisma"; +import { AdjustClient } from "@/lib/platforms/adjust"; +import { openCredential, sealCredential } from "@/lib/platform-credential"; +import { + completedAdjustRange, + parseAdjustPlan, + validateRange, + type AdjustPlan, +} from "./adjust-data"; + +export class AdjustSetupError extends Error { + constructor( + public status: number, + message: string, + ) { + super(message); + } +} +const configHash = (plan: AdjustPlan) => + createHash("sha256").update(JSON.stringify(plan)).digest("hex"); + +export function parsePlan(input: unknown) { + try { + return parseAdjustPlan(input); + } catch (error) { + throw new AdjustSetupError( + 400, + error instanceof Error ? error.message : "Invalid configuration", + ); + } +} +export function parseRange(start: unknown, end: unknown) { + try { + return validateRange(String(start ?? ""), String(end ?? "")); + } catch { + throw new AdjustSetupError(400, "Select a valid window of at most 31 days"); + } +} +export async function adjustClient(orgId: string, appToken = "") { + const auth = await prisma.platformAuth.findUnique({ + where: { orgId_platform: { orgId, platform: "adjust" } }, + }); + if (!auth?.isActive || !auth.apiKey) + throw new AdjustSetupError(404, "Connect Adjust first"); + return new AdjustClient({ + apiToken: openCredential(auth.apiKey, `${orgId}:adjust`), + appToken, + }); +} + +export async function connectAdjust( + orgId: string, + userId: string, + token: unknown, +) { + if ( + typeof token !== "string" || + !token.trim() || + token.length > 4096 || + /\s/.test(token.trim()) + ) + throw new AdjustSetupError(400, "Invalid API token"); + const encrypted = sealCredential(token.trim(), `${orgId}:adjust`); + const apps = await new AdjustClient({ + apiToken: token.trim(), + appToken: "", + }).listApps(); + await prisma.platformAuth.upsert({ + where: { orgId_platform: { orgId, platform: "adjust" } }, + update: { apiKey: encrypted, isActive: true }, + create: { orgId, userId, platform: "adjust", apiKey: encrypted }, + }); + return { connected: true, apps }; +} + +export async function verifyPlan(orgId: string, plan: AdjustPlan) { + const client = await adjustClient(orgId, plan.appToken); + const app = (await client.listApps()).find((app) => app.id === plan.appToken); + if (!app) + throw new AdjustSetupError( + 400, + "App is not accessible through this connection", + ); + const events = await client.listEvents(); + for (const metric of Object.values(plan.metrics)) { + if (!events.some((event) => event.id === metric.id)) + throw new AdjustSetupError( + 400, + "Selected metric is not available for this app", + ); + } + if ( + plan.productId && + !(await prisma.promotedApp.findFirst({ + where: { id: plan.productId, orgId }, + select: { id: true }, + })) + ) + throw new AdjustSetupError( + 400, + "Product does not belong to this workspace", + ); + return client; +} + +export async function saveAdjustPlan(orgId: string, plan: AdjustPlan) { + await verifyPlan(orgId, plan); + const extra = JSON.stringify(plan); + const account = await prisma.platformAccount.upsert({ + where: { + orgId_platform_accountId: { + orgId, + platform: "adjust", + accountId: plan.appToken, + }, + }, + update: { extra, displayName: plan.appName, isActive: true }, + create: { + orgId, + platform: "adjust", + accountId: plan.appToken, + displayName: plan.appName, + extra, + }, + }); + return { id: account.id, plan }; +} + +export async function previewAdjust( + orgId: string, + plan: AdjustPlan, + startDate: string, + endDate: string, +) { + const client = await verifyPlan(orgId, plan); + return { + plan, + startDate, + endDate, + data: await client.getMappedReport(plan, startDate, endDate), + }; +} + +export async function syncAdjustApp( + orgId: string, + appToken: string, + startDate: string, + endDate: string, +) { + validateRange(startDate, endDate); + const account = await prisma.platformAccount.findUnique({ + where: { + orgId_platform_accountId: { + orgId, + platform: "adjust", + accountId: appToken, + }, + }, + }); + if (!account?.isActive || !account.extra) + throw new AdjustSetupError(404, "Save this app configuration first"); + const plan = parsePlan(JSON.parse(account.extra)); + const startedAt = new Date(); + const payload = await previewAdjust(orgId, plan, startDate, endDate); + const serialized = JSON.stringify(payload); + if (Buffer.byteLength(serialized) > 4_000_000) + throw new AdjustSetupError( + 400, + "Report is too large; narrow the reporting window", + ); + const snapshot = await prisma.$transaction(async (tx) => { + // Lock the app configuration before publication; changed or disconnected + // configurations must not receive an in-flight report from the old plan. + const lock = await tx.platformAccount.updateMany({ + where: { id: account.id, orgId, extra: account.extra, isActive: true }, + data: { updatedAt: new Date() }, + }); + if (lock.count !== 1) + throw new AdjustSetupError( + 409, + "Configuration changed; preview and sync again", + ); + const old = await tx.adjustReportSnapshot.findUnique({ + where: { accountId: account.id }, + }); + if (old && old.syncedAt > startedAt) + throw new AdjustSetupError( + 409, + "A newer sync completed; reload the report", + ); + const data = { + orgId, + configHash: configHash(plan), + startDate, + endDate, + data: serialized, + syncedAt: new Date(), + }; + return tx.adjustReportSnapshot.upsert({ + where: { accountId: account.id }, + create: { accountId: account.id, ...data }, + update: data, + }); + }); + return { + ...payload, + syncedAt: snapshot.syncedAt.toISOString(), + stale: false, + }; +} + +export async function readAdjustSnapshot(orgId: string, appToken: string) { + const account = await prisma.platformAccount.findUnique({ + where: { + orgId_platform_accountId: { + orgId, + platform: "adjust", + accountId: appToken, + }, + }, + include: { adjustSnapshot: true }, + }); + if (!account?.isActive) + throw new AdjustSetupError(404, "App is not configured"); + const snapshot = account.adjustSnapshot; + if (!snapshot) return null; + const plan = parsePlan(JSON.parse(account.extra || "{}")); + return { + ...JSON.parse(snapshot.data), + syncedAt: snapshot.syncedAt.toISOString(), + stale: snapshot.configHash !== configHash(plan), + }; +} + +export async function syncConfiguredAdjust( + orgId: string, + startDate: string, + endDate: string, + automatic = false, +) { + const accounts = await prisma.platformAccount.findMany({ + where: { orgId, platform: "adjust", isActive: true }, + }); + const results: Array<{ + appToken: string; + success?: boolean; + error?: string; + }> = []; + for (const account of accounts) { + if (!account.extra) continue; + let plan: AdjustPlan; + try { + plan = parseAdjustPlan(JSON.parse(account.extra)); + } catch { + continue; + } + if (automatic && !plan.autoSync) continue; + try { + const range = automatic + ? completedAdjustRange(plan.utcOffset) + : { startDate, endDate }; + await syncAdjustApp(orgId, plan.appToken, range.startDate, range.endDate); + results.push({ appToken: plan.appToken, success: true }); + } catch { + results.push({ + appToken: plan.appToken, + error: "Adjust sync failed; previous snapshot retained", + }); + } + } + return results; +}