Product changelog
What we shipped, version by version
Hand-written by the engineering team. Every entry maps to a real feature, fix, or security update — no marketing language, just facts.
- v0.25CoreImprovement
Trust upgrades: 95% confidence interval on savings + Founding Member Beta + AI cost guard
This release is almost entirely about one thing: making the numbers we show you trustworthy. The savings headline now carries a 95% confidence interval underneath, turning "looks precise" into "honestly precise". The Founding Member Beta landing page is live with the ROI money-back guarantee (final terms pending counsel review). The two greyed-out buttons on AI suggestion cards are now a clear "Advisory" tag so they no longer look broken. Behind the scenes we added result caching (same data → instant + free) and a generous monthly fair-use ceiling that only catches runaway scripts — real customers never see it.
- The "Saved this month $X,XXX" headline now shows a 95% confidence interval underneath (e.g. $900 – $1,560), computed by propagating the persisted Bayesian ROAS posterior through the counterfactual via error propagation (the only uncertain term in net savings is counterfactualSpend × predictedRoas). Variances sum across independent changes. New lib/savings-tracker/interval.ts with 10 unit tests covering σ recovery, variance summation vs SE, string / missing-field degradation. Upgrades the headline from a marketing point estimate to a defensible statistic with honest uncertainty
- "Building baseline · X / 5 measurements" indicator for small accounts. When the month has 1-4 AI changes (not yet a stable estimate), a small amber chip tells you the number will firm up as more measurements accumulate, instead of dropping a bare figure on you. Zero changes still uses the existing onboarding card (connect accounts + let the AI run 7 days)
- /[lang]/beta Founding Member landing page live (en / zh-CN / es), 50 founding spots, anchored on the ROI money-back guarantee: annual plans — if our holdout-measured savings come in below the subscription fees you paid, we refund the difference, capped at 100% of fees. Slogan "We prove your savings — or your subscription is free" lands on the homepage + dashboard top once counsel signs off on the final terms
- AI suggestion cards: the two permanently-disabled Dismiss / Apply buttons are replaced by a clear "Advisory" badge per card plus a one-line panel note: "These are advisory suggestions — review and apply them in your ad platform. One-click apply lands once your ad account is connected and approved." Honest framing, reads as intentional rather than broken
- AI suggestion result caching: identical input (data snapshot + provider preference) returns the cached result within a 12h TTL — no LLM call, zero token cost. The cache key changes the moment the data changes (new day, new connected account) or the provider preference switches, forcing a fresh generation. New ai_usage_events table serves both jobs (cache source + monthly counter); every DB call fails OPEN so a database hiccup never blocks a customer's recommendations
- Generous monthly fair-use ceiling — 200 free/trial, 600 Professional, 3000 Business per month — all sit comfortably above the heaviest modelled real usage (~150/month). Only catches runaway scripts. Hitting it shows a friendly "data only refreshes daily, email [email protected] and we'll raise your limit" message, never a hard error or an anxiety-inducing usage meter
- Admin / review backend pinned to English + Chinese only (internal operator tool — no Spanish needed). Fixes a small bug where /es/admin previously rendered the "last seen" relative time in Chinese (relativeTime only supports en/zh, and a Spanish admin falling back to Chinese was nonsensical); now consistently English
- Tests 767 → 788 (+21 new, covering the CI math + 3 cost-guard route paths: cache hit / over-cap / real-generation persisted). Lint clean, build green, zero production errors. Version 0.24.0 → 0.25.0
- v0.24CorePlatform
Shadow mode live: optimizer wired to persistence + daily cron + outcome measurement
v0.22-0.23 proved the math works. v0.24 wires it into the production loop — the moment Google Ads dev token approval lands, everything just works. Added lib/ads-fetcher platform abstraction (mock + real-platform stubs), lib/budget-optimizer/persistence (recordRecommendation + recordOutcome + getOrAssignHoldout + daily aggregation), /api/cron/run-optimizer (daily optimizer pass writing budget_changes in shadow mode — never touches real platform budgets yet), /api/cron/measure-outcomes (looks up actual platform metrics 7 days later, computes counterfactual savings, writes change_outcomes). The dashboard SavingsHero now reads real DB rows — empty state when no AI activity, real savings number when there is. End-to-end pipeline: fetcher → optimizer → persist → measure → dashboard.
- lib/ads-fetcher abstraction: mockAdsFetcher (uses MOCK_ACCOUNTS + deterministic noise per (campaignId, windowEnd)) + googleAdsFetcher / metaAdsFetcher / tiktokAdsFetcher stubs (throw with actionable migration message) + getAdsFetcher router checks USE_REAL_ADS_FETCHER env. 13 test paths cover router default, "1" flipping, accidental-on prevention, mock determinism, snapshot ordering, unknown-account null, three-platform stub error messages
- lib/budget-optimizer/persistence: getOrAssignHoldout reads first, falls back to deterministic compute on race; recordRecommendation writes budget_changes (numeric values via .toFixed for precision); recordOutcome writes change_outcomes; listChangesAwaitingOutcome left-joins for unmeasured changes; listChangesForUser/aggregateUserSavings/dailySavingsForUser feed dashboard. Every DB call wrapped in try/catch + structured logger.error so cron survives transient DB hiccups
- /api/cron/run-optimizer: CRON_SECRET Bearer auth → pulls all non-revoked / non-token-expired ad_accounts → per ad_account fetches 8 weeks of history → per campaign assigns/reads holdout (control campaigns skipped early) → recommend() → persists active recommendations as budget_changes in shadow mode (hold/insufficient_data don't pollute the table). Per-ad-account try/catch with captureError so one bad account doesn't kill the whole sweep, structured logger.info summary at end
- /api/cron/measure-outcomes: finds budget_changes ≥ windowDays old without outcomes → fetcher pulls actual spend/revenue for that window → computeOutcome calculates counterfactual netSavings → writes change_outcomes. Configurable via OUTCOME_WINDOW_DAYS env (default 7), capped at 500 changes per run. Records zero-spend outcome when campaign no longer exists (defends against infinite retry)
- Dashboard SavingsHero now reads real DB: loadSavingsForUser parallel-queries month-to-date / trailing-7d / lifetime aggregates + 30-day daily series. When changeCount=0 the hero auto-renders the empty state (already baked in since v0.22), real outcomes drive real number + chart (negative days included honestly). End-to-end loop closes: cron → DB → dashboard
- Tests 644 → 657 (+13 ads-fetcher paths). 0 lint warnings, build green. Version 0.23.0 → 0.24.0
- Production-critical path tests filled in (+37 new tests). lib/budget-optimizer/persistence gets 16 paths covering getOrAssignHoldout (existing / new / control routing / DB-down fallback), recordRecommendation success + failure, listChangesForUser numeric→number conversion + empty on DB failure, recordOutcome success + failure, listChangesAwaitingOutcome + aggregateUserSavings sum + dailySavingsForUser bucket + ascending order. /api/cron/run-optimizer gains 11 route-test paths: auth tri-state + zero-accounts + null externalAccountId skip + treatment-arm persist + control-arm early skip + hold/insufficient don't pollute table + per-account error isolation + meta carrier. /api/cron/measure-outcomes gains 10 route-test paths: auth + OUTCOME_WINDOW_DAYS env + normal outcome + null window → zero-spend outcome + orphan ad_account skip + per-change fetcher error isolation
- v3 change-point detection (fixes the biggest math gap from v0.23). posterior.detectDownwardShift compares spend-weighted ROAS of recent 3 windows vs older windows; flags shifted when ratio < 0.85 and combined conversions ≥ 30. When shift fires AND posterior.mean < target, decision policy hard-flips to decrease_budget at -maxDeltaPercent (defends against the "max expected revenue" objective recommending +20% on a campaign already underperforming its target). Backtest slow-decline directional hit rate **33% → 69%** (the biggest v0.23 gap closed) at the cost of seasonal 56% → 36% (sinusoidal trough triggers decrease right before rebound; recentN=3 is the chosen balance, with v4 periodicity detection planned to distinguish "trend vs cycle"). 12 new test paths cover detectDownwardShift. Honest record in backtest-v0.24.md: total savings $174K → $146K (-16%) is the trade — fewer lucky upside grabs in exchange for fewer wrong-direction calls on dangerous declines
- v4 periodicity detection (fixes the v3 seasonal collateral). posterior.detectPeriodicity computes lag-4 Pearson autocorrelation on the DETRENDED ROAS series (lag 4 = monthly cycle assuming weekly windows, most common ad-platform seasonality). When correlation ≥ 0.5, decision pipeline suppresses change-point shift — "recent decline" was actually a known-cycle trough that will rebound. Critical design: detrend (subtract linear OLS fit) BEFORE autocorrelation; otherwise a slow monotonic decline false-positives as periodic (both halves are positively correlated when both are trending down). Backtest seasonal hit rate 36% → **47%** (+11 pp), seasonal savings $10K → **$19K** (+89%); slow-decline stays at 69% (no periodicity, new path doesn't affect it). 10 new test paths cover detectPeriodicity: clear sinusoid, noisy sinusoid, monotonic-trend-no-false-positive, flat, white noise, threshold + minWindows. Backtest report v0.24.md updated to reflect the v4 path
- MCP server delivers on the long-standing /vs/adwhiz promise. New mcp-server/ sub-package, zero runtime deps (pure Node 18+ ESM + fetch + hand-rolled JSON-RPC 2.0 over stdin/stdout), lets Claude Desktop / Cursor read AdWhiz state directly. 5 read-only tools: list_ad_accounts / list_recommendations / get_savings_summary / get_recent_activity / get_quota — all routed through /api/v1 + Bearer auth. User install: git clone + add path to claude_desktop_config.json + set ADWHIZ_API_KEY env. **Deliberately no mutation tools** (apply / pause) — those wait until the optimizer exits shadow mode. Full README.md covers Claude Desktop + Cursor + generic MCP client install paths + troubleshooting + security model. 4 stdio-protocol smoke tests (spawn subprocess, exercise real protocol): initialize / tools/list / ping / unknown method
- Production path ready: when ANY of Google Ads dev token / Meta App Review / TikTok Business API approval lands → implement that platform's fetcher → set USE_REAL_ADS_FETCHER=1 → wire EventBridge to call both cron endpoints → savings hero shows its first real "本月已为你省下 $XXX" number
- v0.23CoreUX
Prediction engine v2: time-weighted posterior + probabilistic confidence + response curve
The v0.22 backtest exposed three real issues: (1) slow-decline scenario hit rate 33% (worse than coin flip) (2) confidence was binary-by-construction (3) calibration was systematically overconfident. v0.23 fixes all three. New aggregateRecency exponentially decays older snapshots into one effective snapshot (default half-life ~2.3 windows), decision is rewritten to compute P(new_revenue > current_revenue) probabilistically, elasticity is sampled from Beta(2,5) × 2 (5-15% chance > 1 to model saturated markets). The 6-scenario backtest now shows ~2× total savings vs v0.22 (~$80K → ~$174K), steady loser flipped from -$2.7K to +$208, and calibration is perfect on healthy scenarios (85% bucket → 85-87% actual positive rate).
- posterior.ts: aggregateRecency(snapshots, decay) collapses N snapshots into one effective snapshot with exp(-decay × age) weights. Default decay 0.3, half-life ~2.3 windows. Beta α/β accept fractional sums (conjugate prior math doesn't require integer counts). 12 unit-test paths cover fast path, decay=0, varying decay strengths, cold-start, zero-windows
- decision.ts rewrite: (1) defaults to aggregateRecency over the full history instead of the latest single snapshot — directly fixes the slow-decline "trust the old data" bug (2) confidence formula changes from 1 - 2|δ| to P(new_revenue > current_revenue) using paired posterior + elasticity samples (3) adds budget→ROAS response curve: new_roas = roas × (current_budget / new_budget)^elasticity where elasticity sampled from Beta(2,5) × 2 (support [0, 2], 5-15% chance > 1 to model saturated markets where bigger budget loses money)
- backtest framework: inclusive upper bound on the highest confidence bucket (≤ 1.0) so confidence=1.0 lands in [0.9, 1.0] instead of being dropped. Version-suffixed output path (--out=docs/backtest-vXX.md) for version-over-version diffing in the repo
- Backtest data: 6 scenarios × 20 campaigns × 9 weeks (1,080 total decisions). Steady winner +$56K (v0.22: +$27K), Regime change +$61K (+$29K), Slow decline +$20K (+$7.7K), Seasonal +$27K (+$12K), Sparse +$10K (+$5.5K), Steady loser **+$208 (flipped from -$2.7K)**. Calibration: winner / regime-change 85% bucket → 85-87% actual positive rate (perfect), volatile scenarios (decline / seasonal / sparse) still Δ=-13 to -21% overconfident — v3 will add regime-stability factor
- sampleBeta exported from posterior.ts so decision.ts can share the sampler (previously private). v1 stale "minConfidence=0.99 forces hold" test rewritten as "no candidate clears 1.01 threshold" + new assertion that confidence is a real probability in [0, 1]
- Honest production path: v0.23 is safe for shadow-mode on ANY campaign type (even slow-decline nets +$20K because safety constraints hold). Conservative auto-apply is viable for stable/growing campaigns; declining-campaign auto-apply should wait for v3 change-point detection
- v0.22CorePlatform
Product core: prediction engine + counterfactual savings measurement
Cut the "add more features" reflex and refocused engineering on one thing: making the AI calculate "should this ad get more or less budget" accurately enough that we can honestly tell the user "you saved $X,XXX this month". Added lib/budget-optimizer (Bayesian posterior + decision policy + hard safety caps) and lib/savings-tracker (counterfactual measurement + 15% holdout control arm). Dashboard hero is now the savings number + daily detail with negative days shown verbatim. LLMs are translators, not predictors.
- lib/budget-optimizer/posterior.ts — Beta-binomial conjugate prior (default Jeffreys Beta(0.5, 0.5)) + Marsaglia–Tsang gamma sampler + deterministic mulberry32 PRNG (so audit logs are replayable) → posterior mean ROAS + 10/90 percentiles + relativeWidth
- lib/budget-optimizer/decision.ts — 5-stage pipeline: (1) data-quality gate (conv≥5, relativeWidth≤1) (2) pause gate when posterior p90 < target × 0.5 (3) argmax expected revenue over δ ∈ {-20%, -10%, 0, +10%, +20%} candidates (4) linearity-penalty confidence = 1 - 2|δ| capped at min 0.70 (5) hold when lift < 1% of current to avoid pointless jitter
- lib/savings-tracker/counterfactual.ts — counterfactual revenue uses the PREDICTED ROAS at decision time, not the actual post-hoc ROAS (which would be circular). netSavings = profit_actual - profit_counterfactual. aggregateSavings + rollupDaily roll up for the dashboard chart
- lib/savings-tracker/holdout.ts — SHA-256(salt, userId, campaignId) deterministic bucketing into control / treatment, 15% control by default. Control arm is never touched by AI, giving us an empirical baseline. Salt bumps re-randomise for new model versions
- SavingsHero component — dashboard top huge number "Saved this month $X,XXX" + last-7-day + lifetime + change count + 30-day daily bar chart (negative days rendered in rose, no smoothing). When zero AI changes, renders honest empty state instead of fake "$0 saved"
- 58 new math tests (17 posterior + 17 decision + 13 counterfactual + 11 holdout) cover Bayesian posterior + decision policy + counterfactual measurement + holdout bucketing. Total tests 550→608, 0 lint warnings, build all green
- docs/budget-optimizer-design.md — full design doc: math, safety constraints, counterfactual measurement, shadow→conservative→production validation plan. Single source of truth for future engineers, beta users, and platform reviewers
- v0.21SecurityNewUX
Email verification flow + Dashboard demo-data notice
Closed the signup loop with proper email verification: new table, send-on-register, dedicated landing page, /settings banner with one-click resend. Dashboard KPI strip now wears an honest "demo numbers" banner when no real ad_account is connected — no more new users thinking the sample spend is theirs.
- New email_verification_tokens table (mirrors password_reset_tokens) + auto-send at register (24h expiry)
- /api/auth/email-verify/confirm endpoint: rate-limited, idempotent (alreadyVerified flag), TOCTOU-safe
- /[lang]/verify-email landing + VerifyEmailRunner posts on mount and renders all four outcomes
- /api/auth/email-verify/request resend endpoint: per-user rate-limit, no-op for already-verified
- Settings top banner shows when emailVerified is null with one-click resend (self-hides after confirm)
- Dashboard renders an amber "These are demo numbers" banner + /connect CTA when no live ad_account exists
- /api/account/export GDPR data-portability endpoint: one-shot JSON dump (profile + subs + ad_accounts + api-keys metadata + last-90-day audit_events). Secrets always excluded
- /api/v1/audit-events SDK endpoint: API-key-authed, returns the caller's audit history with ?since + ?limit (max 200)
- instrumentation.ts: inits Sentry + installs globalThis.__sentry when SENTRY_DSN + @sentry/nextjs are both present; otherwise silent no-op
- /legal/privacy Your Rights rewrite: each right now points to its self-serve path (Settings / /api/account/export) — users don't need to email support to exercise them
- Content-Security-Policy-Report-Only header live (default-src self, frame-ancestors none, upgrade-insecure-requests, etc) — observation period before flipping to enforcing mode
- /api/csp-report sink: accepts both legacy report-uri and modern Reporting-API formats, logs each violation via logger.warn, per-IP rate-limited at 200/min
- /api/v1/usage endpoint: SDKs read their tier + RPM limit + remaining + reset_at without a probe request (same data as X-RateLimit-* headers)
- README refreshed: 80+ pages, 25+ APIs, 11 tables, 135 tests, 13 migrations; enumerates the public API endpoints + Sentry / CSP wire-up notes
- Stripe webhook diffs the pre-upsert row: plan change → subscription_changed event, status→canceled → subscription_canceled. Idempotent across Stripe retries
- RecentActivity renders subscription events with the from→to transition; /api/auth/email-verify/request gains 6-path test coverage (unauth / rate-limit / already-verified / unverified send / orphan)
- Auth.js signIn / signOut events now capture x-forwarded-for + UA via next/headers, populating audit_events ip/ua; /settings#security renders "Signed in · provider · IP X.X.X.X"
- /api/v1/me gains 5 test paths (unauth / orphaned key / success envelope / null last_login / count fallback)
- Token-refresh cron writes ad_account_token_expired audit on the connected → token_expired transition; users see "Reconnect X" in /settings#security
- UnverifiedEmailBanner now also lives at the top of /dashboard — OAuth users with provider-verified email never see it
- Vitest suite up to 140 tests (/api/v1/me + /api/auth/email-verify/request)
- /api/v1/audit-events gains ?kind= filter (comma-separated, returns 400 invalid_kind on typo) — SIEM consumers can subscribe to just sign_in / api_key_* etc
- /docs/security-whitepaper upgraded from keyPoints outline to full body article (encryption, audit trail, self-serve rights, CSP, observability, AI data policy)
- /api/v1/me gains an email_verified boolean — SDK consumers can gate unverified-user UX
- /api/v1/api-keys lists the caller's full key set (lastUsedAt + active boolean), hashedKey never returned
- /admin "new users" KPI gains week-over-week +N (+X%) badge so operators see acceleration at a glance
- CONTRIBUTING documents the audit-events checklist, the vi.hoisted test pattern, and the optional Sentry wire-up
- Vitest suite up to 149 tests
- /api-reference public page synced to 13 endpoints (adds /audit-events / /api-keys / /usage / /changelog)
- lib/api-auth.ts validateApiKey gains 7 tests sealing the /api/v1 auth path (missing header, malformed, unknown hash, match, lastUsedAt fire-and-forget, test vs live env)
- lib/api-response.ts gains 10 tests covering every branch of apiOk / apiError / preflightResponse / authError
- Vitest suite final count: 166 tests across 27 files
- observability.ts + refresh-tokens cron TODOs resolved; per-row failures now emit logger.warn for CloudWatch
- Settings server actions: 12 tests across updateProfile / updatePreferredLocale / updateAiProvider / updateEmailPreferences / dismissOnboarding
- Vitest suite up to 178 tests across 28 files; README header stats refreshed
- /api/contact gains 7 test paths including honeypot drop + DB error 500
- /api/auth/register gains 8 test paths (dual-insert ordering, Accept-Language locale selection, verify-token failure still 201)
- Vitest suite final final count: 193 tests across 30 files
- settings actions: full destructive path coverage — changePassword (6) + deleteAccount (5) + disconnectAdAccount (3), including bcrypt mocks + audit-event assertions
- Vitest suite up to 207 tests
- Homepage + /vs/adwhiz inject schema.org JSON-LD (SoftwareApplication / Product with Offer pricing); rich snippets light up on Google
- .github/workflows/ci.yml runs lint+test+build on every PR (with concurrency cancel-in-progress); decoupled from deploy.yml
- /api/cron/prune-tokens weekly cleans password_reset_tokens (>7d) and email_verification_tokens (>30d) leftovers; 5 test paths
- infra/README cron table + schedule-expression cheatsheet now includes the 2 new prune crons
- /api/auth/email-verify/confirm gains the 429 rate-limit test (asserts the DB short-circuits — never queried)
- Vitest suite up to 213 tests
- Vitest include broadened to .test.{ts,tsx}; JsonLd component gets 4 tests covering </script> escape defense + nested objects
- /api/v1/openapi.json: OpenAPI 3.1 spec for the 5 stable endpoints + every schema. SDK authors can run `openapi-typescript` / `openapi-generator` directly against it
- lib/audit-events centralizes AUDIT_EVENT_KINDS + ALLOWED_KINDS — API route validation, TS union, OpenAPI enum all share one source
- CONTRIBUTING adds an "Adding a public API endpoint" checklist; README mentions the OpenAPI spec URL; /api-reference gains an OpenAPI button
- Vitest suite up to 223 tests (OpenAPI route: 6 paths covering shape / endpoint list / security override / ETag / kind sync / cache headers)
- New-device sign-in email alert: UA-hash fingerprint stored in audit_events.meta.device_fp; signIn event checks 30-day window and fires email when fingerprint is new AND user has prior sign_ins (suppresses on first-ever login to avoid welcome-email spam)
- /api/auth/password-reset/{request,confirm} route tests: 14 paths covering the "always 204" anti-enumeration contract + DB-error-doesn't-leak + double-update success
- Vitest suite final count: 237 tests across 35 files
- /about, /legal/privacy, /legal/terms, and the AI system prompt now correctly say "Google Ads, Meta Ads, and TikTok Ads" — the last few 2-platform stragglers
- /api/oauth/google/start route tests: 5 paths verifying CSRF state cookie (HttpOnly + SameSite=lax) + fresh-per-request token
- package.json bumped to 0.21.0
- /api/cron/trial-reminders tests (7 paths covering plan-label + locale mapping + daysLeft floor); /api/cron/monthly-digest tests (6 paths covering cross-account dedupe + locale fallback + null-email skip)
- Vitest suite final final final count: 255 tests across 38 files; nearly every active cron endpoint now has coverage
- instrumentation.ts hides the @sentry/nextjs import behind a runtime-computed module id so the bundler doesn't force install at build — `npm install @sentry/nextjs` is all an operator needs
- Vitest suite up to 120 tests (/api/v1/audit-events 6 paths + /api/account/export 5 paths — verifies attachment header, ISO serialization, and that passwordHash / hashedKey / encrypted tokens never appear in the dump)
- /api/v1/changelog gains ?limit=N (1..50) + ?since=YYYY-MM-DD: limit returns the newest N, since filters by entry date — together they cover the canonical incremental-poll pattern. ETag now fingerprints (lang, limit, since, content) so consumers never get a false 304 across variants. OpenAPI + tests updated to match
- /api/v1/accounts gains 6 route-test paths: unauth pass-through, success envelope, spend_usd 2-decimal precision, environment echo, X-RateLimit-* header injection, OPTIONS preflight
- /api/v1/recommendations gains 8 route-test paths covering unauth, default status=pending, all-supersets-pending, strict applied filter, unknown-status-returns-empty (not 400), SDK shape, rate-limit header, OPTIONS
- /api/v1/audit-log marked deprecated — RFC 8594 Deprecation: true + Sunset (2027-05-23) + Link rel=successor-version steering to /audit-events. Non-breaking migration signal; 7 route-test paths added
- /api/v1/accounts/[id]/campaigns gains 6 route-test paths: unauth, unknown-id 404 + account_not_found code, full success envelope, spend_usd + cpa_usd precision, rate-limit header, OPTIONS
- /api/v1/recommendations/[id]/apply + /dismiss gain 11 route-test paths together: apply covers unauth / applied_at fresh / rate-limit header; dismiss covers empty body / text reason / >500-char truncation / non-string-coerces-to-null / malformed-JSON tolerance / OPTIONS
- /api/v1/changes/[id]/rollback gains 5 route-test paths: unauth, id-echo + reverted_at within request window, audit-id uniqueness across calls, rate-limit header, OPTIONS
- /api/v1/changelog gains Last-Modified + If-Modified-Since 304s — curl -z / wget --time-stamping clients can now do date-based conditional GET. Sends both ETag and Last-Modified; ETag wins when both client headers are present (RFC 9110 §13.1). OpenAPI updated; 5 route-test paths cover the new branches
- /changelog.rss now emits Last-Modified anchored to the newest entry — RSS aggregators (Feedly, NewsBlur) use If-Modified-Since to save bandwidth. The route stays force-static; we just advertise the timestamp
- New "Incrementally polling /changelog" section on /api-reference with 4 curl snippets covering ETag / Last-Modified / ?since / ?limit — SDK authors see the recommended pattern at a glance
- /api/oauth/meta/start + /api/oauth/tiktok/start gain 5 route-test paths each, mirroring google/start: unauth redirect, lang default, not_configured, state-cookie CSRF flags, per-request fresh state
- /api/cron/refresh-tokens gains 9 route-test paths: CRON_SECRET-unset 503, wrong-bearer 401, missing-header 401, crypto-unconfigured 503, zero-candidates 200, all-three-platforms dispatch + refreshed, refresh-failure writes token_expired + audit event, unknown-platform failed, GET-method compatibility
- /api/ai/recommend gains 6 route-test paths: no-session 401, session-without-id 401, providerOverride from users.aiProvider, missing-user-row falls back to env, AI-throws-Error 500 + captureError context, non-Error-throw degrades to ai_failed
- /api/stripe/webhook gains 7 route-test paths covering all security-critical gates: missing-signature 400, missing-secret 400, SDK-throws-on-bad-sig 400 with message echo, unique-violation dedup returns duplicate:true without Sentry spam, non-dedup DB error 500 + captureError(stage=dedup_insert), handler failure rolls back the dedup row + 500 + captureError(stage=handler), happy path 200 received
- /api/oauth/google/callback gains 11 route-test paths: unauth, state-mismatch (+ no-cookie sub-branch) burns cookie, user-decline error echo, missing-code, not-configured, crypto-unconfigured, token-exchange-failure + captureError ctx, no-refresh-token (skips insert), persist-failure suppresses audit, happy-path redirect + audit + cookie clear
- /api/oauth/meta/callback gains 7 route-test paths: unauth, state mismatch + cookie burn, user-decline echo, step-1 short-token exchange failure, step-2 long-lived exchange failure, long-lived token written to BOTH access + refresh columns (Meta has no separate refresh), persist-failure suppresses audit
- /api/oauth/tiktok/callback gains 8 route-test paths: unauth, state mismatch + cookie burn, code-fallback (TikTok natively uses auth_code), exchange failure, N advertiser_ids → N rows + N audit events, empty advertiser_ids inserts a single null-id row, access + refresh encrypted independently (TikTok rotates both), persist-failure suppresses audit
- /api/v1/recommendations/generate gains 7 route-test paths: unauth pass-through, body echo + SDK envelope, recommendation shape, job_id uniqueness (5 calls ≥ 2 distinct), malformed-JSON echo={}, rate-limit header, OPTIONS preflight
- lib/email gains 10 unit-test paths: sendEmail covers no_api_key skip + Resend OK + rejected-message-without-Sentry-spam + thrown-error captureError ctx; sendContactNotification routes to SUPPORT_INBOX with replyTo + handles null message; sendPasswordReset URL-encodes token + zh-CN copy switch; sendNewDeviceAlert truncates UA at 80 chars + null IP renders em-dash placeholder
- OpenAPI info.version bumped 1.0.0 → 1.1.0 to reflect the additive surface accumulated since 1.0: /changelog gains ?limit + ?since + Last-Modified + If-Modified-Since, /audit-log gains RFC 8594 Deprecation headers. No breaking changes; SDKs built against 1.0 stay compatible
- /vs/adwhiz: stale "8 core endpoints" copy synced to 13 (+ OpenAPI 3.1 + ETag/304 incremental polling). /api-reference /audit-log row now flags Deprecated + sunset 2027-05-23 + steers to /audit-events
- src/proxy.ts (Next.js 16 middleware) gains 14 test paths: i18n redirect (default / zh / en / path-segment preservation / locale-already-present skip / unknown-locale falls back), /api skip, request-id upstream precedence (x-request-id → x-amzn-trace-id ALB → cf-ray Cloudflare → fresh UUIDv4), matcher excludes _next/static and other hot paths
- lib/mock-ads-data gains 8 unit-test paths: MOCK_ACCOUNTS fixture invariants (all three platforms, unique account ids, unique per-account campaign ids, non-negative metrics + conversions ≤ clicks), summarizeAccount sums correctly + counts only active + zero-on-empty + leaves rounding to the route layer
- /changelog gains a client-side "N new since you last visited" pill — localStorage-backed (dxk_changelog_last_visit), dismissible, silently seeds on first visit (no banner flash). Pure countNewSince helper extracted + tested with 7 paths (today / N=2 / all-new / future date / strict > boundary / empty / lexical YYYY-MM-DD ordering across year+month rollover)
- lib/docs-articles + lib/blog-posts gain 18 unit-test paths: ARTICLES/POSTS fixture invariants (unique slugs, known CategoryKey, no orphans, bilingual body, ISO publishedAt, localised date strings) + articleHref (redirect → absolute, default → /docs/[slug], lang-aware) + relatedArticles/Posts excluding seed + respecting limit
- Extracted fingerprintUA from auth.ts into lib/ua-fingerprint with 8 unit-test paths (null / undefined / empty all return null; non-empty UA → 16-char lowercase hex; deterministic on same input; sensitive enough to differentiate Chrome 120 vs 121; handles ~500-char pathological UAs). New-device login alert is unchanged — the refactor only makes the algorithm independently testable + reusable
- Security headers gain Cross-Origin-Opener-Policy: same-origin-allow-popups (hardens against tabnabbing / opener-leakage while keeping OAuth popups working) + Cross-Origin-Resource-Policy: same-site (allows app.7275.com ↔ 7275.com under one registrable domain, blocks bare cross-site embeds). Low-risk modern defaults; existing OAuth / Stripe flows untouched
- lib/audit-events gains 4 taxonomy test paths: tuple shape preserved / all 10 stable slugs present / enforced snake_case (renaming would break historical audit rows) / ALLOWED_KINDS Set strictly rejects typos + empty + uppercase. The kind enum is the single source of truth read by route validation, OpenAPI spec, and the settings UI
- Extracted eventLabel + summaryLine helpers from RecentActivity component + 14 unit-test paths: eventLabel covers all 10 slugs + unknown-slug graceful fallback; summaryLine covers sign_in/out tri-state (provider+IP / IP-only / em-dash / non-string-provider safety), api_key name·env concat, ad_account platform-suffix trim, subscription_changed both shapes (plan_changed / created), canceled, generic meta-null with IP fallback, and double-null em-dash
- CrossPlatformPanel aggregatePlatform + platform helpers exported with 11 unit-test paths: arithmetic sums; ROAS is spend-weighted (not arithmetic mean — defends against tiny-budget-high-ROAS outliers); spend=0 yields ROAS 0 not NaN; conv=0 yields CPA 0 not Infinity; only active campaigns counted; empty-array safe; platformLabel/Letter/Accent map all three + Tailwind from-/to- class strings stay intact
- AuditLogTable filterEntries + Filter type exported + 8 unit-test paths: all returns full list, ai-only / user-only (excludes system), three status filters, source-order-preserving filter (no internal sort), empty array under any filter returns []
- lib/ai/schema gains 10 invariant test paths: RECOMMENDATION_TYPES preserves 7-verb stable order + snake_case + uniqueness; JSON schema top-level requires both summary + recommendations; the recommendations array is capped 1..8 (defends against AI runaway); the enum mirrors the tuple exactly (drift would silently drop AI completions); all 7 per-rec fields required; confidence enum closed to low/medium/high; target requires only accountId; additionalProperties:false at every nesting level (closes the hallucinated-field loophole)
- lib/ai/index activeProvider exported with 6 unit-test paths pinning the resolution precedence: per-user override > AI_PROVIDER env > hardcoded "anthropic" fallback; null/undefined/empty all pass through to env; case + whitespace are normalised (" OpenAI " → openai); unknown values at either layer fall through cleanly (cohere → env, then palm → hardcoded)
- lib/ai/prompt gains 7 unit-test paths: SYSTEM_PROMPT contains no ${} template strings or ISO dates (preserves Anthropic prompt-cache hits across requests) + all three platform names present + tool/JSON output directive enforced; buildUserPrompt embeds the accounts snapshot inside a ```json fence that JSON.parses back to the input, handles multi-account + empty input + closes with an imperative ask that pushes the model toward concrete recommendations
- lib/ai/anthropic + lib/ai/openai gain 5 + 6 = 11 adapter test paths: missing-API-key clean throw, tool_use / json_schema parse + usage forwarding, missing-tool-block or null-content throws (defends against corrupt empty recommendations landing in the UI), wrong tool name ignored, SDK client cached (one ctor across many calls), openai also covers JSON.parse propagation should strict-mode ever loosen
- /settings/api-keys actions gain 14 test paths: createApiKey covers unauth, empty name, whitespace-only name, 81-char name (>cap), live + test env prefix, missing/invalid env defaults to live, success path produces 41-char plaintext + 13-char prefix + writes api_key_created audit, DB failure returns db_error AND skips audit (no ghost rows in /settings#security), each call yields a fresh plaintext; revokeApiKey covers unauth, missing id, successful revoke writes api_key_revoked audit, already-revoked does not double-audit
- OpenAPI spec gains 5 semantic tag groups (Identity / Quota / Activity / Keys / Public) — Swagger UI / ReDoc / Postman auto-fold endpoints under these section headers. Every path is tagged (no orphans render as a phantom "default" group). Purely additive; existing SDK consumers untouched. 2 new test paths assert tags-declared + descriptions present + every operation tagged with a declared group
- lib/google-oauth gains 8 HTTP-path tests for exchangeCode + refreshAccessToken: 200 parses access/refresh/expires_in; form-urlencoded body carries all 5 required Google fields; 4xx throws with status + body-truncated-to-240; 5xx also throws; network errors propagate unwrapped; refresh-flow sends grant_type=refresh_token (distinct from authorization_code, and correctly omits redirect_uri); refresh 401 (expired token) message is operator-actionable
- lib/meta-oauth + lib/tiktok-oauth gain 15 HTTP-path tests: Meta's two-step short → long-lived flow uses GET + query-string (Meta quirk vs POST + body), long-lived swap uses grant_type=fb_exchange_token and omits `code`, 4xx throws with status; TikTok uses POST JSON envelope (`auth_code` field NOT `code`, HTTP 200 + non-zero business code throws), refresh hits a distinct endpoint from access_token, and both tokens rotate together on refresh (a foot-gun the test pins down)
- v0.20SecurityPlatformNew
Audit + open-API batch: audit_events table, security headers, public API, anti-spam
Stood up a full security-event audit trail (sign-in, password change, API key issuance, OAuth grants) — users see their own activity on /settings, operators see the whole org on /admin. Plus baseline security headers, a contact-form honeypot, and a public /api/v1/changelog JSON endpoint.
- New audit_events table (kind / userId / ip / ua / JSONB meta) with writes from 6 event types
- /settings Security shows your last 10 events; /admin gets a global last-12 panel
- /api/cron/prune-audit-events sweeps rows older than 180 days weekly (override via AUDIT_RETENTION_DAYS)
- Baseline security headers across all routes: X-Frame-Options DENY, X-Content-Type-Options nosniff, Referrer-Policy, Permissions-Policy
- Contact form honeypot: silently drops bot submissions (no DB write, no email) while returning the normal success envelope
- Public /api/v1/changelog JSON endpoint (lang switch, CDN-friendly caching) sharing data with /changelog.rss
- API key lastUsedAt now rendered as relative time on /settings/api-keys; auth middleware stamps it on every authenticated request
- Auth.js signOut event also writes to audit_events — full session-lifecycle audit loop
- Sentry shim: lib/observability forwards via globalThis.__sentry when SENTRY_DSN is set — no hard dep on @sentry/nextjs
- relativeTime promoted to src/lib/relative-time.ts (5 surfaces share it) with years-unit coverage
- Footer copyright year auto-rolls (template token + new Date().getUTCFullYear()); package.json bumped to 0.20.0
- /audit-log header note clarifies it covers AI campaign changes; sign-ins / API keys / etc live on /settings#security
- /api/v1/me now returns last_login_at + connected_accounts_count so SDK consumers can render rich account state
- /api/v1/changelog gains ETag + If-None-Match 304 — long-polling consumers stop redownloading unchanged content
- audit_events gains a (userId, createdAt DESC) composite index — /settings query drops from seq-scan to index-only
- /admin Recent users gains a Plan column (free muted, professional indigo, business violet chip)
- docs/observability.md documents the full Sentry wire-up (instrumentation.ts template + rationale for skipping withSentryConfig)
- Vitest suite up to 103 tests (ETag + 304, Sentry shim 4 branches, prune-audit-events cron 4 paths)
- v0.19UXPlatform
Preferences-persistence batch: email opt-ins, onboarding sync, login history, last incident
Pulled the dashboard / settings state that previously lived only in localStorage down into the database so it survives device switches; added an MRR-estimate KPI and "last seen" column to admin; status page now pulls incidents from a real data module instead of hardcoded literals.
- New users.emailWeeklyDigest / emailRecAlerts columns + EmailPreferences component; monthly-digest cron now filters opt-outs at the query level
- Onboarding "dismissed" now persisted to users.onboardingDismissedAt — same state across devices; prefilledDone hints now derive from real ad_accounts / api_keys rows
- Auth.js signIn event stamps users.lastLoginAt; Settings shows "Last sign-in X days ago", Admin gets a Last seen column
- Settings profile section now shows "Member since YYYY-MM-DD (X months ago)"
- Status-page incidents moved to src/data/incidents.ts module; header gains a "Last incident: X days ago" pill
- Admin KPI grid gains an MRR-estimate card (active subs × plan monthly price, yearly subs at 80% monthly-equivalent)
- Changelog data extracted to src/data/changelog.ts; new public /changelog.rss feed
- Vitest suite up to 82 tests (incidents data helpers + mrrContribution + RSS route smoke)
- v0.18NewSecurityPlatform
Production-ready batch: 3-platform OAuth, password reset, Stripe idempotency, observability
Shipped everything needed before a real launch: end-to-end OAuth lifecycle for Google / Meta / TikTok (connect → refresh → disconnect), forgot-password flow, Stripe webhook idempotency dedup, AES-256-GCM token encryption at rest, structured logging + error-capture hook, CI test baseline (63 unit tests), and operator-side connected-accounts admin panel.
- Real OAuth flows for all three platforms with AES-256-GCM token encryption at rest
- EventBridge cron endpoints: token refresh every 30 min, daily T-3 trial reminders, monthly performance digest
- Stripe webhook idempotency via stripe_events dedup table; added invoice.payment_failed / trial_will_end / payment_succeeded handlers
- Forgot-password end-to-end: emailed token, 15-min expiry, rate-limited, no account-existence oracle
- Observability: lib/logger (structured JSON) + captureError() error hook + X-Request-Id correlation header
- Vitest with 63 unit tests covering crypto, rate-limit, OAuth, API key validation, admin allowlist, stripe helpers, health endpoint
- Settings now shows connected accounts with one-click disconnect; Admin gets a recent-connections table + expired-token KPI alert
- New users.preferredLocale column — cron emails respect per-user language, no more hardcoded zh-CN
- v0.16NewUX
Light/Dark theme + dedicated "DEXUN AdWhiz vs AdWhiz.ai" comparison page
Full-site light/dark theme support (system-follow or manual toggle), plus a dedicated comparison page laying out where DEXUN AdWhiz outperforms AdWhiz.ai across platforms, MCP, REST API, annual billing, and affiliate.
- All pages (home, docs, blog, dashboard, legal) support both themes — eye-friendly for late-night ops
- Sun/moon toggle in the top-right; first visit defaults to system preference
- New /vs/adwhiz page: 12-row feature checkmark grid, 4 deep-dive topics, 3-tier pricing comparison
- v0.15New
Annual vs monthly billing toggle + 20% off on annual
Added monthly/yearly segmented toggle to Pricing. Annual saves 20% (pay for 10, get 12). Stripe backend routes to STRIPE_PRICE_*_YEARLY price IDs automatically.
- Pro: $63.20/mo annual (was $79) · Business: $223.20/mo (was $279) · Enterprise: $639.20/mo (was $799)
- "Save 20%" badge uses indigo→violet gradient matching the footer affiliate badge
- v0.14NewUX
Homepage trust section + tiered audit frequency in Pricing
New trust section between Features and Pricing showing only checkable facts (control-group-verified savings, ROI money-back guarantee, publicly disclosed operator DEXUN INC). Pricing now describes per-tier audit cadence: daily / 4-hour / hourly.
- Trust section: control-group-verified savings · ROI guarantee · operated by DEXUN INC (no fabricated customers / numbers / reviews)
- Pricing: Pro daily (20/day) · Business 4-hour (100/day) · Enterprise hourly (high quota)
- v0.13PlatformNew
TikTok Ads support — we're now three-platform
TikTok Ads is the third supported ad platform. Mock data, i18n strings, Pricing copy, FAQ, and Hero bento all updated. MCP server exposes TikTok-specific tools (creator authorization, Spark Code, etc.).
- Dashboard: Google / Meta / TikTok account cards side-by-side, KPIs auto-aggregated
- AI recommendations now suggest cross-platform shifts (e.g. "move Meta lookalike to TikTok Spark")
- MCP tool set adds tiktok.list_spark_ads, tiktok.creator_authorize, and more
- v0.12New
Affiliate program launched (25% → 75% lifetime recurring)
/affiliates is live: four-tier commission (25/35/55/75%), 60-day cookie, lifetime recurring (not first-payment-only). Footer adds an affiliate badge in the Company column.
- Starter 25% / Silver 35% / Gold 55% / Diamond 75%, auto-promoted by active referral count
- Net-30 payouts, PayPal or Stripe transfer, $50 minimum
- v0.11NewUX
Docs / Blog / API reference / MCP guide all shipped
/docs and /blog ship as dynamic [slug] routes: 7 full articles + 21 structured previews + 6 blog posts. /api-reference enumerates 8 REST endpoints with error codes. /mcp-guide explains Claude / Cursor integration in detail.
- Shared markdown-lite renderer (**bold** + [text](url)) across Docs and Blog
- Docs has popular-shortcuts row; Blog has a featured-post card and topic tag cloud
- v0.10UXNew
Hero redesigned (left text / right bento) + Apple-style segmented control
Homepage Hero rebuilt as left-text / right-bento, with Google / Meta / TikTok mini cards on the right. Nav swap to Apple-style segmented control for tab switching.
- Three platform mini-cards rotate real account names + status + 7-day ROAS
- Primary CTA changed from "Get started" to a 7-day free-trial message
- v0.9NewUX
About / Contact / Coming-soon support pages + contact form API
/about (parent company, registration, business info), /contact (form + DB persistence + email notify), /coming-soon (shared placeholder taking ?topic= param) all live. Every Footer link now routes somewhere meaningful.
- contact_submissions table + /api/contact endpoint + Zod validation
- About discloses legal entity DEXUN INC, Seattle WA registration, parent DEAO GROUP LIMITED
- v0.8NewSecurity
Four legal pages live (Privacy / Terms / Refund / Cookies)
Privacy, Terms, Refund, and Cookies legal pages all published. Governing law moved to Washington State + AAA arbitration + King County. Contact email unified across pages: [email protected].
- Shared LegalLayout: sticky left ToC, prose right, dark-mode aware
- v0.7NewPlatform
Stripe subscriptions + Customer Portal + Webhook integration
/[lang]/checkout server-side redirect to Stripe Checkout. /api/stripe/webhook handles customer.subscription.* events. Dashboard shows subscription status + Customer Portal entry point.
- subscriptions table + users.stripeCustomerId; status mapped to active / trialing / past_due / etc.
- 7-day free trial, no card charged during trial, auto-converts to monthly after
- v0.6NewSecurity
AI recommendations layer (Anthropic + OpenAI dual-provider)
AI abstraction layer supports runtime switching between Anthropic Claude and OpenAI (AI_PROVIDER env). Structured-output schema validation. Dashboard shows mock accounts + real AI-generated recommendations.
- /api/ai/recommend route: account snapshot in, 3-5 structured recommendations out (type / target / reason / expectedImpact)
- v0.5NewSecurity
Auth.js v5 integration (Credentials + Google + GitHub)
Drizzle adapter wired into Auth.js v5 beta. Email / Google / GitHub login. JWT sessions. Signup endpoint with bcrypt hashing + Zod validation.
- Dashboard is route-protected, unauthenticated users redirect to /login
- v0.1Launch
DEXUN AdWhiz public preview
Full-stack baseline live: Next.js 16, Tailwind v4, Drizzle, Supabase Postgres. Bilingual (ZH/EN) home, Pricing, FAQ, Footer shipped.
- Operated by DEXUN INC (Seattle, Washington, USA), parent company DEAO GROUP LIMITED
Curious what we're working on next?