Changelog
What shipped, newest first.
docs/CHANGELOG.md
User-visible and structural changes. Newest first. Format: YYYY-MM-DD.
| Date | Type | Change | Refs | By |
|---|---|---|---|---|
| 2026-10-06 | infra | Nonprod can be deployed: deploy nonprod rolls one signed candidate onto the nonprod droplet. Dispatch it with a main commit, or empty for the latest candidate. It checks both images' signatures name that commit, rolls their digests through prod's deploy/roll-vm.sh, records a GitHub Deployment (environment: nonprod, task: deploy:candidate), and checks /api/v1/version serves the commit. Nonprod runs at http://144.126.249.116.nip.io with an empty database and mock SEO providers. Prod is untouched. | #383, D-061 | Claude |
| 2026-10-06 | infra | Every candidate is also copied to Google Artifact Registry. container-candidate.json adds a GAR destination (us-central1-docker.pkg.dev/verjson-ci-640463/marketing-studio-candidates), which the org's release workflow requires before it will promote a candidate. The generated workflow, validator, helper and contract test are unchanged at a835699 (they read the destinations from the config). Nothing is deployed differently. | #381, D-060 | Claude |
| 2026-10-06 | change | The "Get a demo" settings are read at runtime, not baked into the web build. The API reads DEMO_FORM_TOKEN and DEMO_CALENDLY_URL and serves them at GET /public/demo-settings; the dialog asks once, the first time it opens, and asks again on the next open if that request failed, so an API blip cannot leave it saying "not connected" for the rest of the visit. One web image now serves every environment, which release candidates need (#381, #387). deploy.yml fills both from the same NEXT_PUBLIC_* repository variables it baked in before, so production behaves exactly as it did; the web image no longer takes those build args. | #400 | Claude |
| 2026-10-06 | fix | Release candidates publish under their own package names. The first publish on main was refused (permission_denied: write_package): ghcr.io/verjson/marketing-studio-api and -web already exist as internal org packages from 2026-08-13 that are linked to no repository, so this repository's workflow cannot write them. Candidates now go to marketing-studio-candidate-api / -web, which this repository creates and owns. The older packages are untouched. | #381 | Claude |
| 2026-10-06 | feat | Every merge to main now also builds signed release candidates of api and web. container-candidate.yml, generated from the org contract, publishes ghcr.io/verjson/marketing-studio-candidate-{api,web}:0.9.0-rc.<run> plus :sha-<commit>, with SBOMs and Cosign signatures; PRs build both without pushing. Nothing deploys them yet: deploy.yml still builds and rolls prod as before, until the release deploys (#383, #384). The web image's default API URL is now the same-origin /api/v1, which every deployment uses, because candidates take no build args. Local and PR builds keep passing their own URL. | #381, #387, D-059 | Claude |
| 2026-10-06 | feat | A nonprod server exists. ms-nonprod (144.126.249.116, s-2vcpu-4gb, nyc3) comes from the same Pulumi program as prod, as a separate nonprod stack with its own DO project, firewall and SSH deploy key. deploy/pulumi/index.ts now reads the per-stack names from config, with prod's values as defaults, and refuses a non-prod stack that reuses them. Nothing serves there yet beyond the placeholder: the app waits on #308 (MinIO) and the release deploy (#383). | #379, #387, D-058 | Claude |
| 2026-10-06 | chore | A new droplet can start MinIO again. The image prod runs is now also at ghcr.io/verjson/marketing-studio/minio@sha256:a1a8bd4a…, copied off the prod droplet by mirror-minio.yml with its layers checked against the original. docker-compose.prod.yml reads MINIO_IMAGE; unset, it resolves to prod's existing image, so Docker's config hash for the service is unchanged and prod's MinIO is not recreated. Nonprod (and a rebuilt prod) set it to the GHCR copy. | #308, #379 | Claude |
| 2026-10-06 | feat | GET /api/v1/version reports which build is running. Unauthenticated, Cache-Control: no-store, answering { commit, release }: the commit the API image was built from (SOURCE_COMMIT, now baked in by deploy.yml) and the release it was deployed as (RELEASE_VERSION, set at runtime by the release deploys still to come). A malformed value of either refuses the boot rather than being served. Phase 2 of the release-driven deploy work: the promoter will check it before moving a release to prod. | #380, #387 | Claude |
| 2026-10-06 | fix | The workflow contract test's "must not contain" checks now actually run. Under set -e, bash exempts a !-negated command from errexit, so all seven ! grep … lines in scripts/workflow-contract.test.sh could never fail: a forbidden ssh-keyscan or privileged lane would have passed. They go through a refute helper now; all of them hold today, and planting a forbidden string fails the test. | #308 | Claude |
| 2026-10-06 | chore | A workflow to copy the MinIO image prod runs into our own registry. No public registry serves quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z any more, so only the prod droplet holds it and a new or rebuilt droplet cannot start. mirror-minio.yml (run by hand) streams the droplet's copy over SSH, checks its layers match the droplet's, and pushes it to GHCR under the repository. A follow-up pins docker-compose.prod.yml to the pushed digest. | #308, #379 | Claude |
| 2026-10-06 | feat | Competitor analysis is a Growth-plan feature. New competitorAnalysis entitlement in PLAN_LIMITS (off on Free and Starter, on for Growth and Scale, listed on the Growth plan card) and limits.competitor_analysis on GET /billing/subscription. A lookup or compare on a lower plan answers 402 before any Instagram call; stored lookups stay readable after a downgrade. The panel shows an upgrade prompt with a link to Billing → Plans in place of the search. Per-org exceptions use the existing PLAN_OVERRIDES. | packages/contracts/src/billing.ts, apps/api/src/modules/competitor-analysis/service.ts, apps/web/src/components/organic/competitor-analysis-panel.tsx | Claude |
| 2026-10-06 | feat | Social listening ships as a project feature: Performance → Social listening. A new sidebar entry under Performance opens four URL-driven tabs — Overview, Mentions, Keyword explorer, Tracked terms — replacing the platform-admin pages (/platform/social-listening, /platform/listening-explorer and /projects/{id}/listening now redirect; the capability probe stays under Platform). Production hardening (D-056): reads are scoped to projects the user can see (another tenant's is a 404); terms, Run now and SocialCrawl searches need an org owner or admin; each organization has a monthly SocialCrawl budget (LISTENING_MONTHLY_CREDITS_PER_ORG, 402 past it, shown as a usage bar) counted from runs, with explorer searches now recorded as runs; the keyword explorer uses only the project's own connected accounts; a term can never run twice at once; per-project term cap; rate limits on Run now and explorer searches; audit entries for term changes, runs and SocialCrawl searches; the sweep skips suspended organizations and archived projects; mentions unseen for LISTENING_RETENTION_DAYS are purged; malformed ids 404. Migration 20261006120000_listening_production (additive). | apps/api/src/modules/listening-tracking/{access,domain,routes,worker,overview}.ts, apps/api/src/modules/listening-explorer/{accounts,explore,routes}.ts, apps/web/src/app/(app)/projects/[id]/performance/social-listening/, apps/web/src/components/listening/*, apps/web/src/lib/{project-ia,section-tabs}.ts | Claude |
| 2026-10-05 | feat | Social listening: brand overview. A new Overview tab (now the default) on Platform → Social listening summarises the stored mentions for a project, by term and published-date range: mentions, views, engagement, creators and new-this-week tiles; where the conversation happens (share of mentions and average engagement per platform); mentions over time; the five most engaging and most viewed posts (each opens the mention detail); top creators; creator size by followers; countries (TikTok); languages; conversation type and paid vs organic (SocialCrawl estimates); Reddit communities. Every labelled panel states how many mentions it covers, views say which platforms report them, unknown totals show as unavailable rather than 0, and a footnote says the counts are a collected sample. No sentiment analysis. Read-only — no SocialCrawl calls or credits. | apps/api/src/modules/listening-tracking/overview.ts, apps/web/src/components/listening/listening-overview.tsx | Claude |
| 2026-10-05 | change | Social listening moved under Platform. One place for the whole service: Platform → Tools → Social listening (/platform/social-listening) with a project picker (kept in the URL as ?project=), then the Mentions and Terms & runs tabs — beside the capability probe and the one-off keyword explorer. /projects/{id}/listening now redirects there. | apps/web/src/app/(app)/platform/social-listening/page.tsx, apps/web/src/components/listening/listening-workspace.tsx, apps/web/src/app/(app)/platform/page.tsx | Claude |
| 2026-10-05 | feat | Social listening: mention explorer. The listening page (/platform/social-listening) opens on a Mentions tab: search across text, titles and authors; filter by term, platform (only platforms that have mentions, with counts) and published date (today, 7 / 30 days, custom range); sort newest, oldest, most engagement, most views or recently collected; 25 per page. Each card shows only the metrics its platform reported, in its own words (Reddit upvotes), with avatar, thumbnail, video length and flags (deleted, NSFW, pinned). A mention opens into a detail view with the full text, engagement, the platform's own fields (TikTok country, downloads, music; Reddit subreddit, upvote ratio, flair; YouTube subscribers…), SocialCrawl's labels marked as estimates, collection history and a collapsible raw payload. Terms and runs move to a Terms & runs tab, unchanged. Reads stored mentions only — no SocialCrawl calls or credits. Reddit's internal account ids are no longer shown as display names. One additive column, engagement_total, backfilled (D-055). | apps/api/src/modules/listening-tracking/{domain,routes,present,normalize}.ts, apps/web/src/components/listening/*, apps/web/src/lib/mention-explorer.ts, migration 20261005120000_listening_mention_explorer | Claude |
| 2026-10-01 | feat | Social listening step 1: tracked terms, scheduled runs, stored mentions. A project can track brand and competitor terms on SocialCrawl (/projects/{id}/listening, reached from Platform → listening explorer → Track terms over time). Each term picks its SocialCrawl sources (same picker and credit costs as the explorer) and a cadence; an hourly sweep re-runs every due term under LISTENING_MAX_CREDITS_PER_TICK, defers anything over it, and stops on an account-level refusal (bad key, no credits). Every run is recorded (credits used and remaining, items, new items, per-source result) and every mention is stored once per term and platform and refreshed on later sweeps, with SocialCrawl's full item kept. 'Run now' runs a term on demand. Platform-admin only until a compliance sign-off (D-054). The analytics plan built on this: docs/LISTENING_ANALYTICS.md. | apps/api/src/modules/listening-tracking/*, apps/web/src/app/(app)/projects/[id]/listening/page.tsx, migration 20261001120000_listening_tracking | Claude |
| 2026-09-30 | feat | Competitor analysis redesigned: one primary action, a who's-winning strip, and four tabs. The Instagram competitor page (hub Competitor tab, and embedded on Instagram analytics as a section) now reads header → search bar → us / score / them strip → tabs (Head to head · Their profile & posts · Posting pattern · What we can see). 'Fetch & compare' runs the existing lookup then the existing compare; a failed compare keeps the lookup on screen, and a reopened recent lookup can be compared from Head to head. Head to head shows every comparison metric as a list (share ring, signed gap with a real minus, leader pill) or roomy cards (semicircle gauge, us/them/gap row), from ONE derived array that also drives the Lead/Even/They-lead counts, so they cannot disagree; the API's own leader rule is kept, including no leader for Following. Profile tab: profile card, bento stats, top posts as a sparkline list or image cards, hashtags. Posting pattern: day and hour histograms in UTC. Presentation only — no endpoint, request, calculation or permission changed; missing values stay '—'. List/Card preference remembered per project. | apps/web/src/components/organic/competitor/*, apps/web/src/lib/competitor-view.ts | Claude |
| 2026-09-30 | feat | Instagram competitor analysis is part of the app, not a separate demo server. The Instagram analytics page and the Instagram Competitor tab now show a native panel. Look up any Instagram Business or Creator account and see its profile counts, average likes, comments and engagement, engagement rate, posting frequency, content types, posting days, top posts and hashtags. Once a lookup lands, Compare with ours sets our account against it metric by metric. Lookups run through the project's OWN connected Instagram account (Business Discovery), so there is no shared token and nothing to deploy beyond the API. Each lookup is stored in the new competitor_snapshots table (the start of a history per competitor), and past lookups reopen from Recent without another Instagram call. A dead Instagram token answers 424 with a reconnect message, never 401, which would have signed the user out. The iframe panel that pointed at 127.0.0.1:8077 is gone; services/instagram-competitor-demo stays as the proof of concept. | apps/api/src/modules/competitor-analysis/, apps/web/src/components/organic/competitor-analysis-panel.tsx, migration 20260930120000_competitor_snapshots | Claude |
| 2026-09-30 | feat | SocialCrawl results are readable, not JSON. The SocialCrawl section of the listening explorer now opens on a feed of every post and comment it returned: cards (thumbnail, platform, author and verified tick, relative time, text, views/likes/comments/shares/saves, engagement rate, estimated reach, SocialCrawl's own intent/niche/sponsored/language labels, and an Open link) or a table, filtered by platform, posts vs comments and free text, sorted by relevance, newest, views, likes, comments or reach, and exported to CSV (UTF-8 with BOM so Excel keeps emoji and accents). The per-source status, credits and raw JSON move into a collapsible 'Source details'. Built on SocialCrawl's single canonical shape (post.* / comment.* / computed.*), mapped in one tested function; null stays '—', never 0. The per-source display cap rises from 25 to 100 so the feed and CSV carry everything a provider page returns. | apps/web/src/lib/socialcrawl-view.ts, apps/web/src/app/(app)/platform/listening-explorer/socialcrawl-results.tsx | Claude |
| 2026-09-30 | feat | Choose which SocialCrawl sources a run spends credits on. Ticking SocialCrawl opens a picker grouped by platform (TikTok, Instagram, X, Facebook, Threads, Pinterest, Bluesky, Reddit, YouTube), each source with its credit cost and a running total, plus 'All searches' / 'Everything' / 'Clear'. A comment source brings along the search it reads from, and unticking a search drops its comment sources, so what is shown is exactly what runs. The picker is built from GET /listening/explore/platforms, which now carries each source's group, credits, depends_on and runnable, so costs cannot drift from the code. POST /listening/explore takes socialcrawl_sources, refuses unknown ids, and refuses SocialCrawl with none chosen (422) — an empty choice never means 'everything'. Also maps SocialCrawl's live canonical shapes (post.*, comment.text), which differ from its per-platform docs. | apps/web/src/app/(app)/platform/listening-explorer/socialcrawl-sources.tsx, apps/api/src/modules/listening-explorer/routes.ts | Claude |
| 2026-09-30 | feat | SocialCrawl as an opt-in third-party source in the listening explorer. A separate 'SocialCrawl (third-party)' row, so every platform's official-API verdict stays honest, covering what the official APIs refuse: keyword search on TikTok, X, Facebook, Threads, Pinterest, Bluesky, Reddit and YouTube, Instagram top posts by #hashtag, and comments on the top TikTok/Instagram post and replies to the top X post. Every row reports the credits it used and what remains; no paid add-ons are requested (judgments=off where documented, no labels or relevance scoring); the key travels only in the x-api-key header and is scrubbed from every payload; success:false envelopes are refusals carrying the provider's error type. It is never pre-selected and never part of 'all platforms' — one run is roughly 20 credits. SocialCrawl collects from logged-out public pages, which those platforms' terms treat as scraping: get a compliance sign-off before any customer use. New setting SOCIALCRAWL_API_KEY. | apps/api/src/modules/listening-explorer/platforms/socialcrawl.ts | Claude |
| 2026-09-29 | feat | Reddit in the social-listening explorer: keyword search, full comment threads and subreddits, through the official Data API. Reddit was listed but never called because Market Studio had no Reddit credentials. It now reads with app-only OAuth (client_credentials against www.reddit.com/api/v1/access_token, one token per run, never returned, and a refused mint is remembered instead of re-asked), sends every call to oauth.reddit.com with the required unique User-Agent, and runs: keyword search by relevance (past month) and by newest (past week), the full comment tree of the top matching post (flattened to three levels with a depth field, collapsed 'more' stubs dropped), subreddit search, and hot posts in the subreddit named exactly after the keyword. No connectable account — keyword search needs none. New settings REDDIT_CLIENT_ID, REDDIT_CLIENT_SECRET, REDDIT_USER_AGENT (documented in .env.example). Commercial use of Reddit's Data API needs Reddit's agreement. | apps/api/src/modules/listening-explorer/platforms/reddit.ts | Claude |
| 2026-09-29 | feat | Instagram competitor demo: compare a competitor with our own account. After Fetch Profile returns, a Compare with ours button appears. It calls the new POST /api/compare {analysis_id, post_limit}, which compares the stored competitor snapshot (no second competitor call) against a fresh snapshot of the account the token administers. Our account is fetched through Business Discovery on its own username, so both sides use the same fields and the same formulas. The page shows followers, following, posts, average likes, comments and engagement, engagement rate and posts per week, each with the difference, the % gap and the leader. It also shows the content mix as shares, each side's busiest posting day, shared hashtags and the competitor's top hashtags we do not use. A metric either side lacks shows no difference and no leader; following never declares a winner; comparing our account with itself is refused (422). The dashboard is now served Cache-Control: no-store, and the app's frame loads it as ?v=2, because browsers had kept an uncached-header copy inside the iframe that a hard refresh did not replace. | services/instagram-competitor-demo/app/analytics.py::compare_accounts, app/main.py, app/static/index.html | Claude |
| 2026-09-29 | feat | Expired social tokens are refreshed instead of failing, and LinkedIn listening reads the Company Page. New modules/channels/fresh-token.ts::getFreshSocialToken refreshes a connected account's token through its OWN adapter and app when it is within 5 minutes of expiry, saves the new token (and any rotated refresh token), runs one refresh per account at a time, and never throws — a failed refresh returns the stale token plus the reason. Nothing refreshed organic tokens before, so a YouTube channel (Google access tokens last one hour) went 401 an hour after connecting; the listening explorer now uses the helper and shows when it refreshed or why it could not. LinkedIn in the explorer now reads, through the Community Management API: the Pages the member administers, the Page's posts (keyword-filtered locally), comments on its 5 most recent posts, reaction totals, share and follower statistics, and @mentions of the Page over the last 30 days — Rest.li 2.0 List(...) / (start:..,end:..) syntax built unencoded around encoded URNs. The Company Page scopes (r_organization_social, rw_organization_admin) are requested at connect only with LINKEDIN_ORGANIZATION_SCOPES=1, because LinkedIn rejects the whole sign-in for an app without that product. Other callers (publisher, metrics, inbox) do not use the refresh helper yet. | apps/api/src/modules/channels/fresh-token.ts, apps/api/src/modules/listening-explorer/platforms/linkedin.ts | Claude |
| 2026-09-28 | feat | Social Listening Data Explorer: type a keyword, see what ten platforms' official APIs actually hand us. /platform/listening-explorer (platform-admin only) runs a keyword across Instagram, Facebook, X, Reddit, YouTube, TikTok, LinkedIn, Threads, Pinterest and Bluesky using the connected accounts and app credentials we already hold, and shows per platform: a Supported / Partially / Not supported verdict with the reason, result counts, raw items, dotted field paths, which listening dimensions (text, author, timestamp, url, engagement, hashtags, media) actually carried values, and the platform's verbatim refusal. Every endpoint is declared up front, so an unreachable platform still lists what would be tried and what it would take. Read-only: nothing stored, tokens never refreshed or returned (payload scrubbed), no scraping — Reddit is listed but never called (no integration), Bluesky uses its open public AppView. No sentiment/topics/trends/alerts. Isolated in apps/api/src/modules/listening-explorer/ + POST /api/v1/listening/explore, apps/web/src/lib/listening-explorer.ts and the page — delete those plus one app.ts mount to remove it. First live run ("Nike"): Instagram partial (Business Discovery returned @nike's profile + 25 posts; hashtag search refused pending Public Content Access); Bluesky partial (account search + author feed work; searchPosts 403s on the public host); the other eight not supported today for want of a connected account/token, or (Reddit) any integration. | — | Claude |
| 2026-10-06 | feat | A project can get its logo after it is created. A project card with no logo shows its initials with a + badge; anyone who can edit the project clicks it to upload a PNG, JPG or WebP, saved as the client-branding logo (the one reports print). Cards and list rows show the logo once set. The create-project dialog's logo is now upload-only and actually stored: branding was on the create contract but create never wrote it, so an onboarding logo was accepted and dropped. | apps/api/src/modules/projects/domain.ts, apps/web/src/app/(app)/projects/project-logo.tsx | Taniyasumbul |
| 2026-10-06 | feat | Share a feed post or reel to the story too, and save custom per-channel posts. “Also share to the story” under the Instagram post type saves an Instagram-only companion post typed story (same media, caption, schedule); always shown for feed/reel on a new post, disabled with the reason until the media is one image or video. With Customize per platform on, Save stayed disabled because it and the content/Instagram checks read the empty shared fields; they now read each channel's draft. | composer-modal.tsx | Taniyasumbul |
| 2026-10-05 | fix | A LinkedIn PDF can go out alongside Instagram and Facebook. The composer allowed a document only when LinkedIn was the sole destination, so a LinkedIn PDF carousel and an Instagram carousel of the same post had to be created twice. With Customize per platform on, a LinkedIn tab (profile or Company Page) now takes a PDF while the other tabs keep their images; in shared mode a line under the media offers the switch. Media rules (one video, images or video, a PDF on its own) now read the tab being edited instead of the shared list, and files whose browser reports no type are judged by extension. | — | Taniyasumbul |
| 2026-10-01 | docs | The feature checklist matches main. Every one of the 170 features on the board was checked against the code: 31 statuses changed (6 up to done: credential vault, CI gate, GDPR export/delete, global search, bulk scheduling, email workflows; 22 up to in progress where real code exists; 4 down where the board overclaimed: Google/GitHub sign-in is switched off, slots and queues have no screen, API keys do not enforce their scopes, trigger.dev has no task). Each change carries a dated note with its evidence and test. FEATURES.md was re-synced to the board (38 rows) and gained the 31 features it was missing; four IDs used for two different features are listed for renumbering. The API-key gap is logged as S-023. | F-137 | Taniyasumbul |
| 2026-10-01 | docs | A capabilities page in the docs. /docs/capabilities lists everything the studio can do, area by area, each marked live, partial or coming, with a per-platform table and a short "not possible" list for sales questions. Checked by hand against main; it also records where the board disagrees with the code (LinkedIn company Pages, Instagram Stories, bulk import, the FAQ on ads and approval). | F-137 | Taniyasumbul |
| 2026-09-30 | feat | "Get a demo" ends with a booked time. With NEXT_PUBLIC_CALENDLY_URL set, sending the demo form opens Calendly's booking page in the same dialog, with the visitor's name, email and note already filled in; booking shows a confirmation. The request is still saved as a lead first when NEXT_PUBLIC_DEMO_FORM_TOKEN is set, and with only the Calendly link the form goes straight to booking. Only an https calendly.com link is embedded, and the CSP's frame-src allows calendly.com. | F-089 | Taniyasumbul |
| 2026-09-30 | fix | A partial project or campaign update no longer resets other fields. Under zod 4, .partial() over .default() still fills defaults in: saving a project's brand voice reset timezone to UTC, currency to USD and channels to none (or failed with "Remove the budget … before dropping the channel"); renaming a campaign emptied its channels. | contracts/projects.ts, contracts/campaigns.ts | Taniyasumbul |
| 2026-09-30 | feat | Client branding, confidential CSVs and a branded PDF report. Project → Settings → Client branding sets the client's name, logo, accent and the "Prepared with OmniChan" credit. Every CSV export opens with two # lines (prepared for, exported by, when). /projects/:id/report is a branded 7/30/90-day performance report with a diagonal confidential watermark on every page, saved as PDF by the browser; totals are organic + paid. | projects/branding.ts, lib/csv-watermark.ts, projects/[id]/report | Taniyasumbul |
| 2026-09-29 | fix | A run stuck in queued is sent again, and a duplicate delivery can no longer kill a running one. A sweep re-dispatches runs queued for over 2 minutes and fails them with a reason after 6 hours; the executor claims a run only from queued (claimQueuedRun), so a second delivery steps aside instead of failing the healthy run. Agent cards now say when a run waits on your approval, with Review & approve. | modules/agents/redispatch-worker.ts, execute.ts, agents page | Taniyasumbul |
| 2026-09-29 | feat | Create is where work is made. Content → Create at /create, opening on work in progress; the Media Library moves to /create/media; the rail reads Create → Approvals → Scheduler. /content and /measure/media redirect. | project-ia.ts, projects/[id]/create/*, next.config.ts | Taniyasumbul |
| 2026-09-29 | feat | The composer shows its stages and only what each channel uses. A Create content → Create copy → Approve bar; the caption is counted against each channel's own limit; Link URL appears only for Facebook, LinkedIn and Pinterest (the adapters that send it); First comment says it is not published — no adapter sends it. | composer-modal.tsx, lib/channel-fit.ts | Taniyasumbul |
| 2026-09-29 | feat | Shapes are chosen by ratio (9:16, 1:1, 4:5, 1.91:1, 16:9) in the resize dialog, ad creative variants and the video picker; pixels stay beside them. The project side menu wears the website's blue. The Scheduler lives at /scheduler; /calendar redirects. | resize-dialog.tsx, creative-uploader.tsx, projects/[id]/layout.tsx | Taniyasumbul |
| 2026-09-29 | feat | The landing page is the OmniChan product site from the Figma, and the build board moved to /build. The page now runs hero → metrics → why → integrations → the five-tab workflow (all five panels, 2× exports) → features (six cards, one open at a time; email, search and analytics rebuilt in markup because they only existed as 1× exports) → FAQ → "Want to go deeper?" → the closing call to action → footer. Every "Get a demo" and "Contact Us" button — which all pointed at a /contact route that does not exist — opens a demo request dialog; requests go through the public email-form endpoint into OmniChan's own project once NEXT_PUBLIC_DEMO_FORM_TOKEN names a form, and until then the dialog says it is not connected instead of pretending to send. The internal board (gate demo, studio, channels, research, roadmap, checklist, build band) moved unchanged to /build, under a "See how OmniChan is built" hero whose four counts are live from data/dashboard. images.qualities allows 90 for UI screenshots. Answers 2–5 of the FAQ are drafts. | F-089 | Taniyasumbul |
| 2026-09-29 | feat | Agents moved into the project, and there is only one Approvals again. The team sat at a workspace /agent that asked which project to work on in a dropdown and remembered it in localStorage — so it opened as greyed buttons with no stated cause and the choice vanished from a shared link. It is now projects/[id]/agents, with the content chain drawn as a rail whose nodes carry state, so "where has this project got to" is answered by looking rather than by reading three identical cards. The agent-proposals page moved in beside it as a tab: it had been called Approvals at /approvals — the same word the project sidebar used for signing off posts a person wrote, and a route redirect-to-first-project already documented as belonging to the project's own approvals, which is why that redirect could never be wired. It is wired now. Also fixed: proposals showed the whole organisation's runs inside one project, and the list slept through the seconds in which a just-started run finished. Adds npm run seed:agents, which gives every live agent something real to read on a fresh database. | F-158 | Claude |
| 2026-09-25 | fix | LinkedIn PDF posts never worked, and now do. The carousel-from-a-PDF feature uploaded correctly and then published through /v2/ugcPosts with shareMediaCategory: "DOCUMENT" — a value that enum does not contain, so LinkedIn rejected every one with 422 :: "DOCUMENT" is not an enum symbol. Documents publish through the versioned Posts API instead, the only endpoint where a urn:li:document: URN means anything; images, video, articles and text stay on ugcPosts, which carries them correctly. The suite that covered this feature had passed all along by stubbing ugcPosts to succeed and reading back the category it had just written — it now scripts both endpoints and asserts ugcPosts is never called for a document. | B-024 · F-041 | Claude |
| 2026-09-25 | feat | A probe that answers what social-listening data we can actually collect, per platform, from live credentials. Rather than reasoning from documentation, npm run listening:probe makes real read-only calls with the deployment's own tokens and writes docs/LISTENING_CAPABILITY.md — a capability matrix plus the fields returned, redacted real samples, the platform's verbatim refusals and its rate-limit headers. Also exposed as GET /api/v1/listening/probe (platform-admin only, since it crosses tenants and returns real comment text). First run: Instagram is the only platform with a live token — own profile/posts/comments and a competitor's profile/posts/stories are available; comment BODIES on another account's post and hashtag discovery are refused; listing @-mentions and any plain-text keyword search are not offered at all. Seven platforms are reported untested, each with what it would take to test them. No sentiment, topics or alerts — discovery only. | LISTENING_CAPABILITY.md | Claude |
| 2026-09-24 | feat | Instagram competitor analysis: a demo that proves what the API will and will not give us. Before building the module, a standalone FastAPI service (services/instagram-competitor-demo) validates the one officially supported route to another account's data — Meta's Business Discovery API (business_discovery.username(...) on the IG User node, Instagram API with Facebook Login). What it returns: id, username, biography, website, followers, media count — plus each recent post's caption, type, media URL, permalink, timestamp, likes and comments — from which the demo computes posting frequency, averages, engagement rate (its own documented formula, not an industry standard), top posts, content mix, hashtags and posting day/hour patterns. What no permission grants for a competitor: insights (reach, impressions, saves, profile views, audience), stories, account_type, contact fields, personal and age-gated accounts, and a direct GET on their media. name, profile_picture_url and follows_count are not documented as public, so the demo ships a per-field probe that reports what the API actually returned rather than guessing. No scraping, no fabricated data: with no token it answers 501 saying so. Surfaced as a Competitor (demo) tab in the Instagram hub, and embedded under Top posts on the Instagram analytics page — the question it answers is the one a reader has next. The embedded page is styled from this app's own tokens (IBM Plex Sans/Mono, the card geometry, Blue 700 for action and Core Blue for data fills) and drops its dark theme and its own masthead when framed, so it reads as the product rather than as a bolted-on tool; it reports its content height to the host, which is the only way the frame can size itself across origins. Verified live against a real token, which corrected two things the docs implied: another account's active stories ARE readable (their insights are not), and an unknown username answers code 110, not 803/100. Sourced findings, live evidence and the reproduction script in services/instagram-competitor-demo/CAPABILITY.md. | CAPABILITY.md | Claude |
| 2026-09-24 | perf | The ads sweeps read once per batch instead of once per row. The proposal generator, the campaign-optimisation agent, the spend-reconciliation worker and the ad-set/ad importers each read inside their loop (N+1). Each now takes one batched read keyed by id before the loop; the generator and the agent share snapshotsByCampaign(). A query-count test guards it: one SELECT per table for four rows. | F-166 · ads/proposals/window.ts, ads/proposals/generator.ts, agents/kinds/campaign-optimisation.ts, ads/reconciliation-worker.ts, ads/sync.ts | Claude |
| 2026-09-24 | perf | Explicit Prisma pool per process, and Postgres sized for the droplet. DATABASE_POOL_SIZE (api 20, worker 10 in prod; Prisma's 2 × CPUs + 1 when unset) is written into the connection string by core/database-url.ts; an operator's own connection_limit wins. Prod Postgres runs with shared_buffers=256MB, effective_cache_size=768MB, work_mem=8MB, maintenance_work_mem=64MB, random_page_cost=1.1, max_connections=100 and shm_size: 256m. | F-160 · core/database-url.ts, core/db.ts, core/config.ts, docker-compose.prod.yml | Claude |
| 2026-09-24 | perf | Performance is measured, not guessed. Server-Timing: app;dur=<ms> (+ Timing-Allow-Origin for the app's origin) on every API response including errors; Prisma statements at or over SLOW_QUERY_MS (default 200) logged with parameterised SQL, never values; npm run perf:sizes reports per-route first-load JS from a production build. New docs/PERFORMANCE.md holds the baseline: Lighthouse /login desktop 96 / mobile 81 (LCP 5.1 s), 99 KB gz shared by every signed-in page, 181 KB gz average route. | F-157 · http/middleware.ts, core/slow-query.ts, core/db.ts, apps/web/scripts/route-sizes.mjs, docs/PERFORMANCE.md | Claude |
| 2026-09-23 | perf | The heavy client libraries load after the page paints. The app shell statically imported the Studio assistant (and react-markdown with it), so every signed-in page carried it before hydrating; the analytics pages carried Recharts and the automation builder React Flow the same way. All load on demand now via next/dynamic behind same-sized placeholders; callers are unchanged (chart facades over *-impl modules). Production build, gzipped: shared-by-every-page JS 227 → 84 KB; average app route first load 283 → 165 KB over 86 routes; analytics overview 372 → 114 KB; SEO/AEO tabs 384 → 127 KB; automation builder 328 → 223 KB; nothing grew. Re-measured after merging main (which added team chat and the walkthrough): shared JS 99 KB with those features included, and still none of react-markdown, Recharts or React Flow in any route's first load. | F-161 · app-shell.tsx, analytics/kpi-trend-chart.tsx, _kit/trend-panel.tsx, _kit/follower-growth-chart.tsx, automation-builder/drag.ts, ui/chart-loading.tsx | Claude |
| 2026-09-23 | fix | The worker container reports its real health, and the roll gate waits for it. It inherited the API image's HTTP healthcheck against a port it never binds, so it was unhealthy forever and a dead worker was invisible. The heartbeat now touches a liveness file after each successful database write; dist/worker-healthcheck.js (dependency-free) judges the file by the same 45 s window /health uses for the row; both compose files use it; roll-vm.sh requires the worker healthy before recording rollback pins. | apps/api/src/core/worker-liveness.ts, core/heartbeat.ts, worker-healthcheck.ts, docker-compose.prod.yml, deploy/roll-vm.sh | Claude |
| 2026-09-23 | perf | The 21 interval workers run in the worker container, not the API. main.ts started every timer-driven sweep inside the request-serving process; they are now startTickWorkers() in tick-workers.ts, hosted by exactly one process chosen with TICK_WORKERS_HOST (default api for dev; worker in docker-compose.prod.yml). Adapter registration is shared by both entry points. Three workers that were never drained on SIGTERM now are. | F-159 · apps/api/src/tick-workers.ts, runtime-adapters.ts, core/tick-workers-host.ts, main.ts, worker.ts, docker-compose.prod.yml | Claude |
| 2026-09-23 | perf | An authenticated request no longer waits on a metering write, and the User row is read once. meterApiCall awaited a plan lookup + usageMeter upsert + overage check after every handler, and the upsert serialised an organisation's concurrent requests on one row lock. api_calls is now tallied in memory and flushed per organisation every 5s and on SIGTERM. requireAuth cached the User row in-process, versioned by the updatedAt Better-Auth returns with the session, so any write to the row is a miss on the next request — a deactivated user is refused immediately. | F-158 · modules/billing/api-call-meter.ts, http/user-cache.ts, http/middleware.ts | Claude |
| 2026-09-22 | feat | A campaign Meta or Google disapproved says "Rejected", with the platform's reason, instead of just "Paused". Everything this product publishes is created paused, so status = paused was true of every campaign a platform went on to disapprove — and it was the only fact recorded; the reason ("Unrealistic Outcomes: claims a guaranteed result") lived only in Ads Manager. Review is now its own dimension, leaving AdCampaignStatus untouched: a new AdReviewStatus (pending_review / approved / limited / rejected / unknown) on each Ad with the platform's reasons and verbatim status, rolled up worst-first onto the campaign alongside campaign-level reasons. A new optional adapter verb getCampaignReview reads Meta's effective_status + ad_review_feedback + issues_info and Google's ad_group_ad.policy_summary + campaign.primary_status; a paused Meta ad with no verdict maps to unknown ("No verdict yet"), never a guessed "approved", and Google's informational DESCRIPTIVE topics are not listed as reasons. A review-sync worker (ADS_REVIEW_SYNC_TICK_MS, 5 min tick) re-reads each published campaign on its own schedule — immediately after a publish, every 15 min while pending, every 6 h once settled — backing off on rate limits (honouring Retry-After) and dead tokens. POST /ad-campaigns/:id/review/refresh checks on demand. The campaign page shows the verdict, the platform's status and when it was checked, and each ad's reasons; the hub's campaign and ad lists show Rejected / Limited / In review beside the on/off state. | modules/ads/review-sync.ts, modules/ads/review-sync-worker.ts, modules/ads/adapters/{meta,google}-review.ts | Claude |
| 2026-09-21 | feat | An ad campaign that fails to publish says so afterwards, and a transient failure retries itself. The reason a publish failed used to live only in the HTTP response — rendered into a banner that a reload erased — so "why is this campaign not on Meta?" had no answer anywhere, and a 429 or a platform 5xx waited for a person to notice and press Publish again. AdCampaign now carries publishError / publishErrorCode / publishFailedAt / publishRetryCount / nextPublishRetryAt, written on every failed attempt and cleared by the next success, and served on the campaign so the detail page renders the platform's own sentence, when it happened, and whether anything will happen next. A new worker (ADS_PUBLISH_RETRY_TICK_MS, 60s) re-runs the same publish path for rate_limited and platform_error only, on the organic publisher's backoff curve, stopping after 5 recorded failures and firing ad_publish_failed to the account's connector. Terminal failures — a bad payload, a dead token, our own readiness gaps — are recorded and never retried. Publishing stays resumable: a retry finishes what the first attempt started and re-creates nothing. | modules/ads/publish-state.ts, modules/ads/publish-retry-worker.ts | Claude |
| 2026-09-18 | fix | Billing: a failed Stripe webhook is retried instead of lost, checkout can no longer open a second subscription, and a late event from an old subscription cannot overwrite the current one. (1) The idempotency claim was committed before the work, so any failure while applying an event (a Stripe blip, a DB error) left it marked processed — Stripe's retry got 200 and the change was lost; a customer could pay and stay on Free. The claim and the work now commit in one transaction, and a concurrent duplicate still resolves to exactly one application. (2) POST /billing/checkout created a new subscription for an org that already had one, billing it twice; it now answers 409 when our row or Stripe shows a live subscription, and expires any checkout the customer left open. /billing sends existing subscribers to the portal. (3) Events for a subscription other than the one on file only apply once that one has ended — checked against Stripe when our row lags — and the org row is locked before Stripe is read, so an older snapshot cannot land on a newer one. | modules/billing/domain.ts, app/(app)/billing/page.tsx | Claude |
| 2026-09-18 | fix | Billing: unpaid no longer keeps paid access, and the seat and paid-channel limits are enforced. Stripe's unpaid was folded into past_due, which is entitled, so an org that never paid kept its plan indefinitely; it is now its own subscription_status value (migration 20260922160000_subscription_status_unpaid) that grants Free limits. The seat cap is checked when inviting (pending invitations count) and again when accepting, under a lock on the organization row; the paid-channels flag is checked at OAuth start, OAuth attach and POST /ad-accounts. Both refuse with 402. | S-020, S-021 | Claude |
| 2026-09-22 | feat | The assistant can change a post that exists, say when you should post and send, draw a picture, and remember what you tell it. Editing a post applies the project's approval rule itself, because the update path does not: given a time it moves a draft straight onto the schedule, which is right in the composer and wrong for a post that still needs its sign-off. From chat, such a post gets a proposed time and goes to review — and a scheduled post whose words change comes off the schedule, since the approval it has is for words it no longer holds. Best times come only from the project's own posts and sends, in its own timezone, with a floor below which it says "too little to tell" instead of naming a lucky slot. An image is drawn only on Confirm — it costs money — by someone allowed to write posts, under the same rate limit as the composer's button, and appears on the card. Memory is a person's own notebook: per project or everywhere, confirmed in and confirmed out, invisible to colleagues, and fed to every later conversation. 17 new tests, 2 new live-eval scenarios. | F-156 | Taniya |
| 2026-09-22 | feat | The assistant says how things went, puts your photo on a post, and writes in your voice. Four reports — an email, an automation, a social channel, the website — each handing the model the project's own average to compare against, because asked "is 22% good?" a model reaches for an industry figure it half-remembers, and a number it did not read from a tool is invented. Each says "missing" in words where the data is, rather than a zero that reads as "did badly". Link clicks count people, not rows: the events table is append-only and one person clicking twice is two rows — the first version of the report counted them twice, and its test caught it. Website numbers are refused outright while the analytics mock adapter is registered, since what it polled is made up. A paperclip in the panel uploads an image or video; the server keeps only keys under the caller's own organization, and the model is shown file NAMES. create_post attaches files by name from that thread and nowhere else, so a prompt-injected URL has nothing to resolve to. Each platform is held to its adapter's rule — which also means Instagram and TikTok posts can now be scheduled from chat when a file is attached. A project's brand voice (tone, audience, guidelines, words to avoid) is set on Settings or by telling the assistant, behind a Confirm; the assistant and the composer's AI caption both write in it. The tool loop gets 8 rounds (a report, a read and a change come before any answer), and an empty completion now says what happened instead of drawing an empty bubble. Also fixes the F-154 row in FEATURES, where a note had landed in the ID cell. 23 new tests. | F-155 | Taniya |
| 2026-09-22 | feat | The assistant writes social posts, creates tags, handles lists — and was finally run against a real model, which found the model was not there. create_post drafts a post, or schedules it for a time or "now", through posts.create — so the project's own approval rule applies and a project that requires review sends the post to Approvals instead of out; the card says which before anybody presses it. Instagram, TikTok, YouTube and Pinterest refuse text-only posts in their adapters, so scheduling one of those without media is refused on the card rather than accepted and failed hours later in the Publish log; a draft is fine. Captions are checked against each platform's real limit, and a platform with several connected accounts asks which one rather than picking a brand's page. Scheduling an EXISTING draft is deliberately not offered: that path flips a draft to scheduled without the approval check create applies. create_tag closes the gap where "tag Ada with Newsletter" could only fail. create_contacts adds up to 50 at once, skips addresses already on the list, refuses a batch with an unusable address rather than half-adding it, and warns when a contact-added automation will pick them up. tag_segment tags a whole segment — checked against the segment's TRUE size first, because the contact list query caps its rows and filtering one page of it would have tagged 500 of an 800-person segment while the card read as everyone — and freezes the people on the card, so somebody who joins between proposing and confirming is not swept in. In the panel, replies render light formatting (bold, lists, small tables) through react-markdown, which renders no raw HTML and strips javascript: links — the reply may quote contact names and scraped pages — while what the person typed is shown exactly as typed. A finished card now has Open, to the screen showing what was made, and only to routes that exist. The live evaluation. Every earlier test drove a scripted fake model, which proves what happens WHEN a tool is called, never that the model will call it. assistant-live.test.ts sends ordinary requests to the configured model via bootstrapAi() and scores what it does — prepares rather than claims, builds the branching journey the right shape, does not invent a follower count or a restriction. Off by default (ASSISTANT_LIVE_EVAL=1), since it costs money and is not deterministic. Its first run could not score anything: the configured OpenRouter key is rejected with 401 User not found, confirmed directly against OpenRouter's key endpoint, and no Groq key is set — so locally every AI feature (the assistant, captions, the agents) currently fails. That needs a new key; nothing in code can fix it. 11 new tests. | F-154 | Taniya |
| 2026-09-22 | feat | The assistant builds whole journeys — branches included — and edits them step by step. Its first version built automations as straight lines, so the part a non-technical marketer most needed help with, the if they opened it / if they did not split that is the heart of the requirements' §7, was left for them to add by hand. create_automation now takes the journey as it is said out loud — email, wait, if opened "Your guide" with a yes and a no — and builds it in one confirm, with the question pointed at that specific email's node rather than at any mail the project ever sent. The same parser serves add_automation_step and update_automation_step, so a condition means one thing whichever way it is written, and get_automation hands the model the journey as nested lists with step ids in the same shape the edit tools accept. Everything is resolved and compiled at proposal time: a field condition runs through the executor's own ruleToWhere, an if opened X may only name an email sent EARLIER on its own path (asking about one the journey has not sent is always no, silently routing everybody one way), and steps after an if are refused rather than guessed onto a path. A build that fails partway removes its half-made draft, which could never have mailed anybody but would have read as the thing that was asked for. Steps only change in a draft; on a live journey the assistant proposes stop_automation_for_editing first and says that everyone inside is held, not lost. Also new: edit, untag, set a custom field on (coerced against its type before the card appears) and unsubscribe a contact — the last writing the flag, a suppression row keyed on the address, and the end of every journey they were in, the same three things the unsubscribe link writes; change a segment with the new match count on the card; edit a draft; and test-send a draft. A test goes to the person's OWN address only — the Test button can send anywhere and is rate-limited per IP for that reason, and a model that can be steered by what it reads is not given that reach — under the same five-a-minute allowance, keyed to the person. The prompt now separates what the assistant has no tool for from what the product has not built yet, so it says forms are not built instead of improvising around them. There is deliberately no re-subscribe tool. 17 new tests. | F-154 | Taniya |
| 2026-09-22 | feat | The assistant does the work now — and a person still presses the button. It used to read the workspace and start agent runs, and stop there, on a principle stated in its own source: approving, sending and publishing "remain buttons a human presses". That principle is kept exactly. What moved is the button. The assistant can now prepare almost anything a person does on the email and automation screens — add and tag contacts, save segments, draft and send broadcasts, build, switch on and pause automations — and approve or reject an agent's output. None of it runs when the model asks. An action tool writes a ChatAction row; the chat draws it as a card with the consequence in words ("Emails 412 people now… This cannot be undone"); and it runs only when the person presses the card's button, which says what it does (Send now, Switch on) rather than a generic Confirm a reader learns to click on reflex. Execution goes through the product rather than around it: confirming calls the same domain function the REST endpoint calls, as the confirming user, so every permission, plan gate and business rule applies unchanged. A Member who confirms a send gets assertCanSend's own "Only an owner or admin can send", recorded on the card as a failure — the card is not a way round a permission, and nothing here re-implements one. The browser is handed an id and nothing else — never a method, a path or a body it could be talked into replaying — so a prompt-injected instruction cannot smuggle a request past the card, which only ever runs what was stored. Confirming is a conditional update from proposed, so a double-click or the same card open in two tabs runs once; this was mutation-tested — with the conditional removed, both the concurrency test and the confirm-after-dismiss test fail, so they guard the claim rather than lean on sendBroadcast's own draft→sending claim to pass. Proposals belong to one person and expire after an hour, because a card pressed the next morning is not the same decision about a list that has since changed. Names the model uses are resolved to ids when proposed, inside the person's scope. An unknown or ambiguous name is an error that lists what exists, so the model asks "the September webinar or the October one?" instead of guessing between two lists of people. A tag whose application would start an active automation says so on its card and is drawn as outward, because "tag Ada with Webinar" can mean "send Ada three emails". An activation that would fail validation is never offered as a card; the reason is given in the conversation. Email bodies the model writes are escaped, never trusted as markup — it may have been reading a scraped page or a contact's name. Four new reads (email overview, broadcasts with per-person opens and clicks, automations with head counts, one contact with why they are not receiving email) give it the real names before it acts. Three things this change fixed on the way. The system prompt contradicted itself — it told the model to call start_agent_run, and eight lines later that it could not start an agent run; both the prompt and the file header described a read-only assistant that no longer existed. The tool-round ceiling rose from 3 to 5, because the last round withholds tools and "read the real names, then prepare the action" did not fit in two. And a first draft of the new prompt section deleted the WHAT YOU DO NOT KNOW block — the anti-invention instruction — by replacing a range that spanned it; the existing test for that block caught it, and it is restored with the list corrected (spend and performance now have tools; follower counts, invoices and billing still do not). The assistant also had no FEATURES or API rows before this; it now has both. Not offered: publishing or scheduling a social post, budgets, ad platforms, deleting anything, billing. 18 new tests. | F-154 | Taniya |
| 2026-09-21 | feat | Landing pages and forms now cover the requirements PDF. New page blocks: video (YouTube, Vimeo, Loom or a video file), testimonials, FAQ and footer. Pages can be embedded in your own website, and the list shows sign-ups per page. Forms gain checkboxes, dropdowns, radio buttons and hidden fields (a fixed value or one read from the page address, e.g. ?utm_source=), and a sign-up made on a landing page records that page as the lead's source. A page with a missing or closed form cannot be published, and an automation whose form, tag or field was deleted cannot be activated. | F-150, F-153 | Taniya |
| 2026-09-21 | feat | Landing pages, built by drag and drop. A new Landing pages tab: start from a lead-capture template or a blank page, drag blocks (hero, text, images, buttons, features, a sign-up form) onto the page and into place, preview on desktop or mobile, set the colours and font, and publish at /p/<your-address>. Sign-ups through the page go through the chosen form, so they land on the list, get tagged and start automations. Also: steps can now be dragged from the palette straight onto the automation canvas, and form fields reorder by dragging their handle. | F-153 | Taniya |
| 2026-09-21 | feat | Sign-up forms that start automations. A new Forms tab builds lead-capture forms — choose the fields, the list new people go into, and the tags they get — and gives each a link and an embed code for your website. Everyone who submits lands on the list (existing contacts are updated, never moved or resubscribed), and an automation set to start when "a form is submitted" now lets you pick which form. The public form reveals nothing about whose it is or who is already subscribed, and is rate limited per visitor and per address. | F-150 | Taniya |
| 2026-09-21 | feat | The automation builder — journeys you can draw. A new Automations tab on Email Broadcasts lists every journey; each opens a React Flow canvas where steps are added from a palette, configured in a side panel (rich-text email, wait, wait-until, condition, tags, field update, end), and connected by dragging. Every change saves as it happens. Problems from the server's validator are listed together and pinned to their steps, and Activate stays off until there are none. A live journey shows how many contacts wait on each step, a Results view counts entered/completed/exited/unsubscribed and per-email opens and clicks, and every contact has a timeline of the steps they took and what they did with each email. API: the dashboard gains counts.unsubscribed and emails[]; the journeys endpoint gains a merged timeline[]. | F-149 | Taniya |
| 2026-09-21 | feat | The automation engine — journeys that walk themselves. The graph, and the worker that walks it. A contact does not run an automation, they SIT in it: AutomationEnrollment holds currentNodeId and nextRunAt, a sweep claims what is due, executes exactly one node, writes a step and lets go. One node per tick is the whole design — a process that dies mid-tick loses one step, which the next sweep re-runs, where one that died walking a journey start-to-finish would lose the journey with nothing in the database saying where it got to. A database sweep rather than the RabbitMQ delay tiers: those exist for retry backoff and top out in minutes, and more decisively a queued message cannot be paused, cancelled or re-planned when somebody edits an automation or unsubscribes — all three of which are ordinary, and all three of which a row handles. Sending the same person the same email twice is the failure this feature would be judged on, and two mechanisms stop it. The claim is a conditional UPDATE … WHERE claimed_at IS NULL, so Postgres serialises two workers on the row lock and exactly one proceeds — there is no window between check and write, which a read-then-update in application code would have. And before any send node runs, the append-only step log is asked whether this (enrollment, node) already executed, so even a defeated claim (a stale-claim reclaim racing a slow worker) costs a wasted tick rather than a duplicate. Both are asserted directly, including three concurrent claims and a deliberately resurrected enrollment. A wait is measured from when the contact arrived at it, not from when the sweep next looked, or a late tick — a deploy, a backlog, a resumed pause — silently re-times everybody's journey. Nine node kinds and two condition families. contact conditions reuse ruleToWhere exactly, so "job title contains Founder" means one thing in this product rather than two implementations that agree until somebody fixes a NULL-handling bug in one of them (that bug was real, in F-146). engagement conditions read EmailEvent, which is the half of §7 that was unanswerable before F-147 — and they are scoped to the enrollment, so somebody who opened last month's newsletter has not opened Email #1 of this sequence. All three entry rules from §11, including the one the PDF calls out: a form submitted twice must not mail twice. Validation before activation (§18) reports every fault at once — a validator that stops at the first turns fixing an automation into a guessing game played one save at a time — and catches the two quiet ones: a condition with only one branch wired, where everybody answering the other way runs off the end and is recorded as completed, and a loop with no wait, which would spin forever because the executor advances immediately between non-waiting nodes. Writing the tests found a real contradiction between the validator and the runtime: the runtime treats running off the end as completing (so an author need not draw an explicit exit) while the validator demanded every node lead somewhere, which would have made a working automation impossible to switch on. The validator gave way. Editing is draft-only and a step with contacts standing on it cannot be deleted — cascading would end their journeys silently, days later, leaving the author a tidier canvas and no idea what it cost. Triggers are raised by the code that causes them (contact added, tag applied, open, click) and are awaited: raiseTrigger swallows its own failures, so awaiting cannot break the action that caused it, while discarding the promise would risk losing the enrollment if the process went away. 32 new tests. The builder that draws all this is F-149 and does not exist yet. | F-148 | Taniya |
| 2026-09-21 | feat | Opens, clicks, unsubscribe and suppression — engagement without the transport rewrite. F-147's plan opens by saying sending must move from SMTP to the Resend API, because SMTP has no event stream and without one there are no opens, clicks or bounces. That conflates two different things, and the distinction is the whole shape of this change: delivered, bounced and complained are things the receiving server tells the sender, and SMTP genuinely hands off and forgets. Opens and clicks are not — the pixel is an image we inject and we serve, and a tracked link is a url on our own domain. Both work identically over SMTP. So the half of engagement that journey branching actually depends on ships here, with the working send path untouched, and the transport move is deferred to the bounce work where it is genuinely required. EmailSend is the general record of one email handed to a transport, deliberately not EmailBroadcastRecipient — that table is one broadcast's dispatch log and cannot serve an automation send, which has no broadcast at all. Its id is generated before rendering (the pixel, every tracked link and the unsubscribe footer are built from it) and the row is written only if the transport accepts the message, so a failed address has no send row rather than a row for mail nobody received. EmailEvent is append-only: "opened twice" is two rows, because every journey condition asks whether ANY row existed before a moment, and the per-broadcast count de-duplicates by send so four opens by one person are one. Links are rows per BROADCAST, not per send — a click token names (send, link), which is 4 rows for four links to ten thousand people instead of 40,000. Three public endpoints, each shaped by the way its kind goes wrong. The pixel always answers 200 with the image, valid token or not: a 404 for a bad token is an oracle confirming which tokens name real sends, and renders a broken image in the inbox of the person being tracked. The click redirect resolves its destination server-side and never from the request — the other way is an open redirect wearing our own domain, worth more to a phisher than the list is — and a forgery is a 404 rather than a redirect to a default, since a default is most of what an open redirect is worth. The unsubscribe GET only describes; the POST acts, because Outlook Safe Links, mail clients and corporate scanners fetch every url in a message and a one-click GET would unsubscribe people who never opened the email. Tokens are HMAC-signed and verified before any lookup, the kind is inside the signed payload (otherwise the unsubscribe token printed at the foot of every message would also be a valid open token), and nothing on the wire carries a contact id, project id, address or destination. Unsubscribing writes three things because three different things read them: the suppression row (every future send), the contact flag (the audience screen), and an event (which campaign made somebody leave — the most useful number in the feature). Suppression is keyed on the address, so it catches a typed one-off recipient with no contact row and survives a contact being deleted and re-imported, which is exactly how somebody who left in March gets mailed again in June. The first reason wins and is never overwritten: a bounce recorded after an unsubscribe would rewrite the history to say a mailbox was broken when in fact somebody asked to be left alone. Un-suppressing deliberately does not re-subscribe a contact. The unsubscribe footer is appended by the renderer rather than left to the composer, so it cannot be forgotten in a draft — and the page it points at ships in the same commit, unlike F-144's verification link which named a route that did not exist for a period. Opens are labelled approximate in the UI, permanently: Apple Mail Privacy Protection pre-fetches images on every message, so a recorded open means "something fetched this". 30 new tests. | F-147 | Taniya |
| 2026-09-21 | feat | A project can hold several apps for one platform — say one Meta app per client brand — and each account keeps using the app that connected it. Platform apps allowed exactly one app per platform (channel_credentials was unique on (project_id, provider)). Now each platform lists every app with its own name, secret and webhook URL, and Add another app adds one. When a platform has more than one, the Connect dialog asks Connect through which app. The choice is signed into the OAuth state, so the code is exchanged with the same app that built the consent URL, and stored on the account (channel_credential_id) — publish, metrics, inbox, reply and webhook resubscribe all resolve through it, because a token only works against the app that issued it. Deleting an app with accounts on it is refused with a count. Existing accounts keep resolving the project's first app exactly as before. Channel platforms only; Google Ads/Analytics stay single. | F-152 · D-050 | Claude |
| 2026-09-21 | feat | A project can hold several Facebook Pages and Instagram accounts from one Meta login. Both Meta adapters walked /me/accounts and kept only the first Page (data[0] / the first with an IG link), so granting three Pages connected one — and "Connect another" returned that same first Page, leaving the rest unreachable. Adapters now expose an optional listProfiles, and the OAuth callback upserts every account it returns, each with its own Page token (a shared one would publish every Page's posts to the same Page). One IG profile that fails to load is skipped rather than failing the whole connect. /me/accounts now asks for 100 per page instead of Graph's default 25. The organic analytics dashboards read data[0] too — they now have an account switcher and open on a connected account rather than whichever sorts first. | F-151 | Claude |
| 2026-09-20 | feat | Broadcasts can target a dynamic segment — F-146 closes. The fourth and last thing the phase owed, and the one the plan said would pay for it before any automation exists: the composer's To picker gains a Dynamic segments group beside the built-in three and the project's own categories. Resolved at send time, never at draft time. That is the entire difference between a segment and a category — a draft written on Monday and sent on Friday goes to whoever matches on Friday. Snapshotting the recipient list when the draft was saved would make a "segment" a static list with extra steps, and would mail people who unsubscribed in between; the test asserts both halves by adding a contact and unsubscribing another AFTER the draft exists. Suppression is in the query rather than a filter somebody can forget. Sending to a segment nobody matches today is a 422 that says so in those words, because "no recipients" reads as a broken draft when it is actually an accurate answer about today. segment_id is a fourth mutually-exclusive target. Both the zod refinement and the widened database CHECK count the named targets instead of comparing them pairwise: three targets need three pairwise clauses and four need six, and the one nobody remembers to add is the one that lets a row hold two audiences — which everything downstream (one audience_key, one label, one chip) then has to answer with two answers, silently dropping one. Setting any target clears the other three on PATCH, for the same reason. The FK is ON DELETE SET NULL like the custom-category one, so a SENT broadcast survives its segment being deleted and audience_label — snapshotted at draft time — keeps the history saying who it went to. A segment_id from another tenant 404s through the same scoped read as everything else. The picker's count says "right now · re-checked when you send" rather than presenting itself as a promise. 6 new tests. | F-146 | Taniya |
| 2026-09-20 | feat | Dynamic segments — a saved question, not a list. The requirements' condition builder (field → operator → value), answering their own example: Webinar Leads · Tag = Webinar-September OR Source = Webinar Landing Page. EmailSegment sits ALONGSIDE EmailCustomAudienceCategory, which stays the static list it already is and which every existing broadcast query reads through a CHECK-constrained column — the two are different things and deliberately not merged: a category is a drawer a contact was filed into, a segment is a question asked of the list, and somebody joins or leaves it without anybody touching their row. Evaluated in the database, never in memory: the contact list carries a row cap, so a segment filtered over one page would quietly omit everyone past it and read "4 match" for a segment of four thousand — and this is the number somebody checks before mailing. Counts are computed per read; there is no stored count, because a dynamic segment whose size is cached lies after the next import. total and sendable are reported separately, since a count including people who can never be mailed overstates every broadcast built on it. Three bugs the tests exist to stop, all of which render perfectly. (1) A range comparison done as text: "9" > "10" is TRUE letter by letter, so numbers and dates compare against their own columns. (2) A negation that drops the contacts missing the field — in SQL NULL NOT ILIKE '%Founder%' is NULL, not TRUE, so a bare NOT { contains } silently excludes everyone with no job title; both negations now spell out the NULL arm, and the test caught this as a real defect during the build. (3) A dangling id WIDENING the segment: an undefined where key is dropped by Prisma, so a condition naming a deleted tag would become "everybody" on the one feature that decides who gets mailed — it compiles to an impossible id instead, and an empty rule set fails closed for the same reason. Ids are validated on write (404, never 403) and tolerated on read, because deleting a tag is something the user did on purpose. The whole set is compiled once at save time, so greater_than against text is a 422 where the person is standing rather than a failure in front of a list. ?segment_id= on the contact list is one more AND term — it narrows an already-filtered view rather than replacing it. Engagement conditions are deliberately absent: opens and clicks need an event stream SMTP does not have, and the builder says so rather than offering a condition that would silently match nobody. 15 new tests. | F-146 | Taniya |
| 2026-09-20 | feat | Per-project custom contact fields. The requirements' "users should be able to create additional custom fields", and the third of four things F-146 owes. Definitions are rows (email_contact_fields) and values are sparse rows beside them (email_contact_field_values), not a JSON blob on the contact: a segment has to JOIN on these, and adding a field to a project with 40k contacts must write nothing. Five types, and the list is deliberately short — every entry is a branch in the editor and, shortly, in the segment evaluator, and a type nothing can filter on is a column pretending to be a feature. A number or a date is stored in a typed column as well as the canonical text, because a range comparison over text is wrong in a way that looks right: "9" > "10" is TRUE lexically, so "spend over 10" would quietly include everyone who spent 9 and the segment would look perfectly correct on screen. A value the type cannot hold is refused at write time rather than stored — "about 40" in a number field is a row no condition on that field will ever match, with nothing to explain the contact's absence. A choice is stored as the OPTION is spelled whatever case was submitted, so one vocabulary means one spelling. Clearing DELETES the sparse row rather than writing "": is empty is a supported operator, and a form that blanks on every save would make every contact match it. key and type are immutable and refused rather than stripped (strictObject) — the key is typed into bodies by hand with no way to find the ones naming it, and the type decides how every value already stored was written, so a silent no-op would leave a caller believing a migration happened. Writing values is one PUT for the whole form, because N requests leave a contact half-updated when the third fails, and one foreign field id fails the entire request rather than being skipped: a partial write tells the caller nothing while leaving them believing a value landed, and these values decide who gets mailed. UI: a Custom fields manager beside Manage tags (showing each field's merge tag verbatim, since that is what gets pasted into a body), and per-type inputs on the contact form — with an explicit "Not set" row on every picker, or a choice field would be impossible to clear once an option had been picked. 11 new tests. Migration applied to dev and test; migrate diff reports no drift. | F-146 | Taniya |
| 2026-09-20 | feat | The contact fields F-146 shipped are now reachable from the product. first_name, last_name, phone and job_title landed as columns, the API accepted them, and nothing in the app could write one: there was no contact form at all, and the CSV importer's header aliases covered only email, full_name, company and category. So the merge field every sequence opens with had a column, a picker in the composer, and no way for a marketer to fill it — {{first_name}} rendered "Hi ,". Two halves of one fix. The importer learns the columns a real CRM export ships (First Name, Surname, Mobile, Job Title, and the obvious spellings of each), and derives the display name from the halves when the file has no single name column — an explicit full_name still wins, because splitting a stored name back into halves turns "Van der Berg" into a first name. A blank cell still means "no new information", not "erase what we know", so a partial re-import cannot null out a phone number. Google Sheets import feeds the same parser and gains the columns with it; the downloadable template now shows all seven. The screen gains an Add contact button and a per-row edit, in one modal for both. The address is absent when editing on purpose — changing it would move that row's delivery history onto a different person, which is the same reason the API's update schema refuses it. source, added and last activity are shown but not editable; a writable "last activity" is a lie waiting to be told. Job title now renders under the name in the list rather than as a column of its own, which would be mostly dashes. The form sends exactly one audience column, matching the segment picker already on each row, and null rather than "" for an emptied box. 6 new tests. | F-146 | Taniya |
| 2026-09-20 | feat | Tags are on the audience screen, not just in the API. The contacts table grows a Tags column: chips with a remove ✕, and a + Tag menu listing the project's whole vocabulary with the applied ones ticked — a toggle, so taking a tag off never needs a second control. No free-text box on that menu, deliberately: applying by name would mean creating vocabulary from a typo, and the endpoint refuses ids it does not own precisely so this control cannot invent one. A filter row sits under the toolbar (its own row, because a segment is one of three-ish and a tag vocabulary grows to dozens, so reading them as one list would suggest they are alternatives — they are not), and the filter is server-side: the contact list carries a row cap, so a client-side filter would quietly omit everyone past it and read as "only four people carry this tag". ?tag_id= 404s a tag belonging to another project rather than answering empty, matching ?category=. A tag manager creates, renames, recolours and deletes; rename saves on blur because a rename keeps every membership, and delete says "removes it from every contact" instead of a bare "are you sure?" — there is no destination picker to soften it the way a category has. | F-146 | Taniya |
| 2026-09-20 | feat | Contacts can carry tags, and a name an email can actually merge. First slice of F-146, and the groundwork for the automation work planned in the plan. Tags are ROWS (email_tags + email_contact_tags), not a string column or a text[]: the cheaper shapes work right up to the first rename, at which point every contact carrying the old spelling is silently orphaned and the segment reading tag = 'webinar' starts matching nobody with nothing on screen to explain it. The join carries provenance — user / import / form / automation — because a contact receiving the wrong sequence is almost always a contact holding a tag nobody meant to give them, and a person doing it and a form doing it lead to different investigations; only a human source stamps a user id, so an audit never reads as though somebody sat and clicked. Names fold through the same audienceSlug the categories use, so Webinar, webinar and Webinar are one tag and the second attempt is a 409. Applying takes ids only and proves every one belongs to the CONTACT's project: a single foreign id fails the whole request, because partial application tells the caller nothing while leaving them believing a label landed — and on this feature a label decides who gets mailed. Re-applying is a no-op (skipDuplicates) rather than a conflict, which the automation path needs: a journey re-entry re-runs its tag action. Contacts also gain first_name, last_name, phone, job_title and last_activity_at; full_name stays authoritative for display and is DERIVED from the halves on write, including on edit, since rewriting first_name and leaving the display name alone is how a mail merge greets someone by their previous surname. Nothing is backfilled the other way — splitting a stored name on the first space turns "Van der Berg" into a first name. The migration was applied to an empty database and migrate diff reports no drift against the schema. 9 new tests. | F-146 | Taniya |
| 2026-09-20 | feat | Google Ads can be configured per project, instead of only in the deployment's .env. Every other platform app moved to the project in F-142; Google Ads was left behind for one reason, written into the code as a comment — the Ads API needs a developer token alongside the OAuth pair, and ChannelCredential had nowhere to put it. It has one now: developer_token + developer_token_last4, encrypted with the same purpose as the client secret and just as write-only, with the Integrations → Platform apps card growing a third field when the provider asks for it. The one thing worth knowing about the resolver: createGoogleAdsAdapter falls back to the deterministic mock whenever any of its three credentials is empty, so a token that is stored but not passed through is indistinguishable at the call site from a project that configured nothing — the adapter builds, credential_source reports project, and the user is offered "Mock Account 1" on a real project. That is the same silent failure F-142 was written to kill on the Meta side, one field further along, which is why the builder takes developerToken explicitly and the regression test asserts on the auth URL carrying the project's own client id rather than on the adapter merely existing. A first save without a token is a 422; a blank field on a re-save keeps the stored one, matching the client secret. | F-142 | Taniya |
| 2026-09-18 | feat | Backlinks are measured, and the env var that used to break them is dead. fetchBacklinksFromProvider was a stub that throws, gated behind BACKLINKS_PROVIDER_API_KEY — so setting the key the docs called "the way to turn real data on" did not enable a provider, it broke the weekly sweep, and the only working configuration was leaving it unset. Replaced with a live DataForSEO /v3/backlinks/backlinks/live call on the same DATAFORSEO_* credentials as rank tracking; the old key is now ignored. Shared plumbing (auth, the 200-carrying-an-error checks, the error taxonomy) moved to modules/growth/dataforseo.ts so the two callers cannot drift — serp.ts was refactored onto it with its 23 tests unchanged and passing. Three mappings are load-bearing, and each is invisible if wrong because the column accepts the value and the UI draws it: (1) domain_from_rank is 0..1000 while BacklinkSource.domainAuthority is 0..100, so stored raw every referring domain reads ten times stronger than it is — rankToAuthority scales, clamps, and returns null rather than guessing 0 for an absent score, since 0 claims a measurement of a worthless domain and null renders as "unknown". (2) A link carries several rel attributes (ugc nofollow is ordinary on forum software) and the enum stores one, so the most specific wins — sponsored is a disclosure obligation and ugc says the site never chose to link, both of which a flatten-to-nofollow would lose. (3) Rows DataForSEO already marks is_lost are dropped, because the reconciler decides lost by ABSENCE — passing them through would re-activate them every sweep and flip a dead link between active and lost forever. Cost controls match the rank tick: one call per (project, domain), BACKLINKS_MAX_CALLS_PER_TICK (25) with overflow deferred unstamped so it stays due, BACKLINKS_ROW_LIMIT (100) as the row tier, and an auth failure aborts the sweep rather than asking a provider that has already refused once per project. 21 new tests. | F-091 | Claude |
| 2026-09-18 | fix | The SEO hub's tab badges said LIVE over invented numbers. Keywords, Rankings and Backlinks were hard-coded hint: "Live" in green regardless of what was behind them, so SHA-256 output carried the same badge as a measurement — on the screen someone takes into a client meeting. New GET /growth/providers reports which surfaces have a configured provider, and the hub derives its badges from it: green Live when measured, amber Sample when mocked, and nothing at all while the status loads, because a brief absence is honest and a wrong green pill is not. Rankings reads the rank provider's state rather than its own, since it plots the same observations the Keywords tab lists and cannot be more live than they are. The endpoint reports reachability only — never which provider, credentials, or cost — and sits behind the router's existing auth, so it is not an unauthenticated capability probe. | F-091 | Claude |
| 2026-09-17 | fix | The keyword and backlink sweeps never ran in production, and the fix for it had already been written. Both modules set DEFAULT_TICK_MS to 1h and keywords.ts documents exactly why — "A day here meant the sweep never ran at all: the timer restarted on every deploy" — but main.ts passed a hard-coded 24 * 60 * 60 * 1000 and 7 * 24 * 60 * 60 * 1000, which silently beat the module defaults. ai-citations.ts got the same fix and carries the same comment, so this was one call site of three, corrected twice and missed twice. The confusion is the tick versus the cadence: the tick is how often the sweep LOOKS for due work, which is nearly free because anything not due is excluded by a where clause, and it resets on every deploy; the cadence is how often one row is actually checked (24h against KeywordRank.checkedAt, 7d against CrawlSchedule.lastBacklinkCheckAt) and lives on the row precisely so a restart cannot reset anyone's schedule. The 7 days passed to the backlink worker was CHECK_INTERVAL_MS copied into the wrong slot. Symptom: a keyword added through the UI sat on "searching…" for up to a day, and on a box that deploys more than weekly the backlink sweep never ran once. Both call sites now pass the env override or nothing, so the module owns its default — duplicating the number in main.ts is what let them drift. growth-worker-ticks.test.ts guards all four growth workers at the call site, and was confirmed to fail against the old value before the fix was restored. | F-091 | Claude |
| 2026-09-17 | fix | A keyword could be tracked in a country no rank lookup can answer for. The contract validates that country is two letters, so ZZ was accepted, the row was created, and it then failed on every sweep forever — permanently "searching…", with the reason only in the API log. createKeyword now 422s with the supported list, and does so whether or not credentials are configured, so a project built against the mock cannot accumulate rows that break the moment DataForSEO is switched on. Also: the rank sweep now stops starting new lookups once the worker is draining. stop() already drained the in-flight call, but nothing stopped the loop queueing the rest — up to SERP_MAX_CALLS_PER_TICK billed requests issued after the process was asked to exit, most of them killed before their rows were written. Deferring costs nothing, because every one is still overdue when the next process boots. | F-091 | Claude |
| 2026-09-17 | feat | Keyword ranks are measured instead of invented. New modules/growth/serp.ts is a seam with two backings: DataForSEO's live organic SERP endpoint when DATAFORSEO_LOGIN/DATAFORSEO_PASSWORD are set, and the existing deterministic mock otherwise. The rank tick, the schema and the UI are unchanged by it existing — Keywords, Rankings and competitor share-of-voice all read this one indirection, so all three become real together. DataForSEO directly rather than through OpenSEO's MCP server, which is what D-023 accepted: OpenSEO is a wrapper reselling this same data, and the API has no MCP client, so calling it directly preserves everything the decision was about (per-call pricing rather than a subscription, no lock-in) without adding an MCP transport and a second service to operate. OpenSEO's ai-search surface is the reason to revisit; it is not needed for rank tracking. Three things the implementation is careful about, each of which silently corrupts history if got wrong: (1) DataForSEO answers 200 OK with its own status_code in the envelope AND per task, so if (res.ok) records failures as successful checks — both levels are checked, and the 402xx billing family maps to auth so the log says "out of credit" rather than "flaky provider". (2) A domain absent from the top 100 is a measurement (rank: null); a lookup that FAILS writes no row at all, because a stored null is indistinguishable from it once it is history and would draw a cliff on the chart that never happened. (3) Domain matching is suffix-on-a-dot-boundary — blog.example.com counts as example.com ranking, notexample.com does not, which a naive endsWith would credit to the user. Country is mapped to DataForSEO's numeric location code from an explicit table and an unknown country throws rather than defaulting to the US, because a US position stored against a keyword tracked in Germany looks entirely normal. Billed per lookup, so the tick carries a SERP_MAX_CALLS_PER_TICK ceiling (default 100) and defers overflow rather than dropping it — the first tick after credentials are added is far larger than steady state. SERP_MOCK_MODE=1 forces the mock, and it is forced under NODE_ENV=test unconditionally so the suite can never bill a real account. 19 new tests. | F-091 · D-023 | Claude |
| 2026-09-16 | docs | INTEGRATIONS.md still described platform credentials as organization-scoped, three weeks after F-142 moved them to the project. Every load-bearing detail in the table was wrong: the unique key ((organization_id, provider), actually (project_id, provider)), the route (PUT /channels/credentials/:provider, actually PUT /projects/:projectId/channel-credentials/:provider), the authorization (org:manage, actually assertCanManageProjectIntegrations), the resolver signature, and the UI location (Settings, actually Project → Integrations). The prose beneath it named OAuthPending.organizationId, a field that no longer exists — the signed state carries projectId. schema.prisma was self-contradictory in the same way: the ChannelCredential model's leading comment said "Per-organization" and "organizationId is nullable" while the field comment twenty lines below correctly described F-142, so whichever a reader hit first decided what they believed. Both corrected, plus the rows the table never had: reads need only project membership, nothing is inherited from a sibling project, GET /channels requires project_id, google-analytics is a provider, and TikTok's field is labelled Client Key. No code changed — prisma validate passes and migrate diff reports no drift, because only /// comments moved. | F-142 | Claude |
| 2026-09-21 | feat | The worker now says when it's dead, and the AI Citations tab suggests its own prompts instead of opening blank. (1) Worker heartbeat. queue.isConnected() only ever reflected the API process's own broker connection — a worker that crashed or was never started left queue: "up" while every crawl silently hung on "no pages crawled yet". The worker now writes a WorkerHeartbeat row every 15s for as long as it's up, independent of job traffic so an idle worker still reads as alive; /health reports worker: "up" | "down" off the freshest row's age (stale past 45s), and the SEO/AEO audit tab shows "Background worker offline — audits paused" instead of a spinner with no explanation. useRunAudit's crawl-landed poll also checks this every ~4s and fails fast with a real message rather than running out its full 30s timeout and 409ing on an empty page set. (2) Prompt suggestions. The AI Citations tab opened on an empty prompt list and asked the user to already know which questions to track — real friction on a project's first visit. POST /growth/projects/{id}/prompts/suggest turns the project's own name/domain/industry/description into 5 realistic starting prompts via one chat() call (same provider seam as the composer's caption/hashtag features, same mock-fallback discipline with no key configured); the tab fires it automatically the moment the list is confirmed empty and renders each as a one-click "add" chip defaulting expected_domains to the project's own host. | core/heartbeat.ts · modules/health/routes.ts · modules/growth/suggest-prompts.ts · modules/growth/ai-citations.ts · lib/health.ts · audit-tab.tsx · citations-tab.tsx | Claude |
| 2026-09-18 | feat | The assistant panel can be enlarged, shows what it actually did, and stops asking you to guess what it can do. (1) Enlarge. It was a fixed 24rem column, which is fine for a sentence and cramped for a table of post performance — the thing it now returns. A header toggle switches to 40rem and the choice is remembered per browser, because someone who wants it big wants it big every time. (2) What the answer was built from. Each assistant turn records which tools produced it, stored on the turn rather than returned once — the entry that matters most is start_agent_run, and a record of an action that vanishes on refresh is not a record. The trace reads "read ad spend" under an ordinary answer and "started an agent run" in warning colour under one that acted, so a number read from your rows is distinguishable at a glance from a sentence the model wrote unaided. (3) Four openers instead of a blinking cursor. An empty chat box is a quiz about what the thing can do; the starters answer it in the space the greeting already occupied. The greeting itself was also wrong — it said "I can't run anything. You press the buttons" — and now states the real boundary: it can read and start a run, it cannot approve, publish or spend. | studio-assistant.tsx · ai/chat.ts · ai/serialize.ts · contracts/ai.ts | Taniya |
| 2026-09-18 | feat | The studio assistant reads your real data and can start an agent, instead of describing buttons. It was strictly read-only with a context of names only — project, connected channels, recent run kinds — so "how did last month go?" got the horoscope its own docblock warned against. And its system prompt hardcoded that the Optimiser, Planner and Site auditor were "NOT built — say so plainly", which the moment those shipped made it confidently deny three features the user is looking at. (1) Tools over memory. Seven of them: projects, published performance with best and worst posts, ad spend and cost per conversion, agent runs, posts published or drafted, site-audit findings, and one that starts a run. The prompt now forbids answering any of those from memory — a number not read from a tool is invented. (2) It can start a run, and nothing more. Starting was never the gated step: a run stops at awaiting_approval and a person still decides, so an assistant that starts one has skipped nothing. Approving is the gated step, which is why there is no approve, publish, schedule or budget tool — and a test asserts none exists. start_agent_run calls the same agentsDomain.create the REST endpoint does, so the plan gate, permission check and project scope apply unchanged. (3) Scope is enforced, never requested. Every tool takes the acting user and builds its own projectScope filter; the model supplies an id, never a tenant, and a project outside scope answers "not found" — a prompt-injected id learns nothing. (4) Money is formatted at the source. Given spend_minor: 144000 the model reported "£144,000" — a hundredfold overstatement, said with confidence. Tools now hand back GBP 1440.00; the field name did not save it and no prompt instruction reliably would. Tool rounds are capped at three, with tools withheld on the last so it must answer in words. | chat-tools.ts · ai/chat.ts · ai/provider.ts | Taniya |
| 2026-09-17 | fix | A storage failure said "Internal server error" and threw the cause away — including on the one path where the money is already spent. core/storage.ts let every raw AWS SDK error escape. onError has no case for one, so it fell to the console.error fallback and the browser received {"detail":"Internal server error"}: the same answer for an unreachable MinIO, a full disk, a missing bucket and bad credentials. On image generation that is worse than unhelpful — the model call happens before the upload, so a storage failure is a picture already paid for, reported as a generic 500 that invites the user to retry and pay again. (1) ensureBucket no longer lies. It set bucketReady = true unconditionally, including when both the head and the create had failed — caching that lie for the life of the process, so one transient MinIO blip left storage permanently broken until someone restarted the API. It now only latches on real success, treats a lost create race (BucketAlreadyOwnedByYou) as the success it is, and otherwise throws. (2) Failures carry their reason. uploadBytes and downloadBytes wrap the SDK error in an ApiError at PROVIDER_ERROR_STATUS (424, not 502/503 — Cloudflare replaces an origin 502/504 with its own body, and the whole point is that the detail survives the edge), keeping the SDK's own words, and log the stack under [storage] for whoever is on the droplet. Thrown from core/, which owns ApiError, so every caller benefits — media library, uploads, image generation, poster compose — without a single route try/catch. (3) A missing object and an outage are no longer the same answer. The poster path caught any downloadBytes failure and reported "That image is no longer in storage" — a 404 telling someone their artwork was deleted when in fact MinIO was down and the artwork was fine. Acting on that means regenerating the picture and paying for it twice. Only a genuine NoSuchKey reaches the not-found answer now. | core/storage.ts · modules/ai/image.ts | Taniya |
| 2026-09-17 | feat | A Drafter run is now decided draft by draft, and the picture is chosen next to the words. Approving a Drafter run took every draft and rejecting binned the lot — including the good ones. Four usable drafts and one weak one is the normal result of a batch, and a single verdict forces you to either publish the weak one or throw away four. (1) Per-draft verdicts that actually bind. Each draft carries a stable id (d1, d2, …) and POST /agents/runs/:id/approve accepts a verdict per draft. A discard means that post is never created — no row, no debris — rather than a preference recorded in prose. The Ideator's per-idea discards still ride along in the note because its proposal has no per-item channel to the server; drafts now do. Omitting the array accepts everything, so the batch decision stays the default and an older client is unaffected; an id no verdict mentions is accepted rather than silently dropped. (2) The image is decided with the copy. Each draft card carries an Add an image button opening the existing generate-or-upload dialog, seeded with that draft's script so the brief starts from what the post is about. The chosen asset travels with that draft's verdict and lands on Post.mediaAssets — a caption approved without a picture is a draft somebody has to come back to, and the moment to judge whether the two belong together is while both are on screen. (3) Drafts get their own review grid, like ideas: caption first, with length against the channel's real ceiling and the proposed publish time, because a wall of flat fields is what rubber-stamping looks like. | contracts/agents.ts · agents/apply.ts · agents/domain.ts · draft-grid.tsx · approvals/page.tsx | Taniya |
| 2026-09-17 | feat | The Planner ships — the last declared agent, and the only one that decides where money goes. plan_generation was declared in AgentRunKind and listed on the Agents page with no engine behind it at all; unlike the Optimiser and the auditor there was nothing to wire up. It makes no model call. Every other writing agent asks a model; this one does arithmetic over the project's own ProjectKpiSnapshot rows and nothing else, because a language model asked to split a budget produces a confident, plausible, invented split — and a number nobody can trace is worse than no number, since it gets approved. It will not invent the total either. That is the most consequential figure on the page, so it comes from one of two real places: a human-supplied monthly_budget_minor, or the sum of the project's existing budget rows for the period (i.e. a re-allocation of money already committed). With neither, it proposes the mix as percentages and no amounts, and says why — a split without a total is useful, a total without a source is a fabrication. A channel earns a share only by clearing three bars: the project declared it, the plumbing exists (a connected ad account), and there is measured spend and recorded conversions. Anything failing one is listed under notConsidered with the reason rather than dropped, because "why is email not in my plan" is the first question a reader has. Allocation is proportional to conversions per unit spent — not winner-takes-all, since zeroing a channel also stops producing the evidence that would correct the decision — with a 5% floor and largest-remainder rounding so the parts sum to the whole. Cadence is the rate the project actually sustained, not a best-practice table. Approving upserts this period's ProjectBudget rows, so a re-run corrects the allocation instead of doubling it, and never touches spentMinor, which only metric ingestion writes. | plan-generation.ts · agents/apply.ts · agent-proposals.ts · agent-roster.ts | Taniya |
| 2026-09-17 | feat | The Site auditor becomes a real agent — and asks before it touches anyone else's server. site_audit has been declared in AgentRunKind and listed on the Agents page since E7, and running it hit the default branch of execute.ts and failed with "not built yet", while growth/rules.ts had been judging crawled pages against the SEO and AEO checks the whole time. Like the Optimiser, this wires the two together and adds no new rules — a second copy of "what counts as a thin title" is how the agent and /growth/audits quietly start disagreeing. The crawl is proposed, not performed. The engine audits pages already crawled and deliberately does not fetch, so a project with nothing crawled left two bad options: crawl anyway — an outward action whose cost the site owner bears, taken before any human approved it, which is the autopilot the gate exists to stop — or fail, which makes a healthy agent look broken. So the empty case is a proposal whose approval starts the crawl, and the approvals screen says so plainly: this is the one agent approval that reaches somebody else's server. A project with no website URL gets a third answer — "set one" — rather than a crawl of nothing. Authorisation stayed where it belongs. runAudit kept its projectScope check and now delegates to a new auditProject(projectId, kind); the agent calls the latter. An agent run carries a project already checked at creation and may be started by an API key rather than a person, so there is no User to re-scope with — and inventing one to satisfy the signature would be a fake authorisation that reads like a real one. Both kinds run by default, worst findings first, with the scores and per-severity counts on the proposal. | site-audit.ts · growth/domain.ts · agents/apply.ts · agent-proposals.ts · agent-roster.ts | Taniya |
| 2026-09-16 | fix | @verjson/video-forge is gone, and CI can install this branch again. That package was the single reason npm ci answered 401 on every run here — a private GitHub Packages dependency, failing every check that needed an install, since 11 September. The alternative was configuring secretless private-registry auth across the pipeline: real security surface, to keep an engine that fits in a few hundred lines of parts already in the tree. Its design was right and is kept — two vendor-free ports with swappable adapters — but the ports are interfaces, and owning them is cheaper than owning a registry, a token and a per-repository CI opt-in. modules/video/ports now holds them. Two adapters replace the vendors. Voice moves to OpenRouter, the key the composer already uses, so video needs no new secret — and ElevenLabs was not merely swapped for convenience: its API answers 402 Free users cannot use library voices, a PLAN message rather than a quota one, so every render would have died at the first step; and its one clear advantage, per-word timings, is stored and read by nothing. Audio there goes through chat completions, requires stream: true, and streams pcm16 only — all three learned by being refused — so the adapter wraps raw PCM in a 44-byte WAV header rather than taking an encoder dependency. Frames move to Playwright + ffmpeg, which the old port's own docs named as the expected replacement. Frames are SEEKED, never played: each animation's currentTime is set and paused, because letting the document play would tie output to wall time and drop frames differently on every machine. One loss, recorded rather than hidden: per-word timings are now absent, and are omitted rather than invented — a caption track built on guessed timings looks finished and is wrong on every line. A test pins that, and says which assertion flips back when captions land. Verified by rendering: 1080x1080 H.264+AAC with real narration and a CSS camera move, in 4.9s. Lockfile now carries zero npm.pkg.github.com references. | modules/video/{ports,adapters} | Claude |
| 2026-09-17 | feat | The Drafter learns the four things it never knew, and the Ideator stops being asked to avoid posts it cannot see. (1) The Drafter had no idea whose brand it was writing for. It received five fields per idea and nothing else — the same gap that made the Scout and Ideator read as generic, still open at the one step that produces the words the audience actually reads. It now loads the project brief. (2) Every caption was written to one ceiling of 2,200 characters, with a code comment noting that "X's 280 is enforced later by the adapter" — so the Drafter knowingly produced copy eight times over X's limit and left the platform to refuse it after a human approved it. Each (idea, channel) pair now carries that channel's real ceiling into the prompt, and screenDrafts re-checks it in code with one corrective retry naming the violations. A prompt rule with no checker is a preference; a checker is the rule. (3) The banned vocabulary only ever screened ideas. The Ideator has dropped unlock/delve/synergy from a hook since Phase 1, and the caption written from that hook was never checked — so the words came straight back in the half people read. The list moves to a shared channel-rules.ts and both agents screen against it. (4) Post.proposedPublishAt existed for an agent to fill and nothing filled it. Its schema comment says it lets "a planner/agent record 'this should go out Tue 9am' without queuing it". The Drafter now takes times from the project's own PostingSlot rows via the existing, DST-correct nextSlotDatetimes, assigning each draft a different slot — never from the model, which would invent a plausible Tuesday. No slots configured means a null time and a run that says so, rather than a guess. (5) The Ideator's prompt has always ended with "do not propose an idea already covered by a recent post" while showing it no posts — the scout's summary is three sentences of analysis, not a list. It now receives up to 40 real captions, published and drafted, because a draft already sitting in the studio is work someone has done and re-proposing it wastes the reviewer twice. The caption ceilings are restated from the adapters because agents run in the worker, where registerConfiguredAdapters() never runs and the registry is empty — channel-rules.test.ts builds every adapter and asserts the map matches, so the duplication cannot drift silently. | channel-rules.ts · content-draft.ts · marketing-ideate.ts · project-context.ts · agents/apply.ts · agent-proposals.ts | Taniya |
| 2026-09-16 | feat | Pictures are generated; type is set onto them. POST /ai/images turns a written brief into stored images, and POST /ai/posters lays a headline, a supporting line and a badge over artwork we already hold — reachable from the composer's Media section, and from a content_image agent run through the approval gate. The two are separate endpoints because text changes and artwork does not: folding them together would redraw the picture on every headline edit, which is a new charge and different artwork under words somebody already approved. Behind a provider seam, with adapters for OpenAI and for OpenRouter — which has no images endpoint at all, an image model there being a CHAT model whose output modalities include image. Two entirely different protocols, and nothing above the seam learns which drew the picture; it also means generation works with no new secret, since this deployment already holds an OpenRouter key. Bytes are returned rather than a provider URL, which expires in about an hour and would give an asset that works while you look at it and is gone by publish. Type is drawn as vector paths, not as text. The first version used font-family and was wrong in a way every test passed: sharp rasterises through librsvg, which resolves families via fontconfig, and an unknown family is silently swapped for a default — the poster rendered in the wrong typeface on every machine without the fonts installed system-wide, including the developer's. An @font-face data URI does not help either; librsvg ignores it. Both measured, not assumed: the same string in the vendored family and in Helvetica produced byte-identical ink. Paths need no system fonts, no fontconfig and no fc-cache, and a missing file throws rather than substituting. Of 54 candidate OFL faces, 36 survive opentype.js's incomplete GSUB support — Instrument Sans and Outfit did not, hence Work Sans. Three layouts (over, band, product) because they are three designs rather than three settings of one, and Project.brandKit carries colours and logo so nobody restates the accent per poster. Headlines are fitted with real glyph widths; counting characters was wrong by a third on capitals. A source key from another workspace answers 404, not 403, and headline text is escaped before it enters an SVG. +69 tests. | modules/ai/{image-provider,image,poster}.ts, agents/kinds/content-image.ts | Claude |
| 2026-09-16 | fix | Opening Chrome's autofill dropdown crashed the page. useGlobalSearchHotkey read e.key.toLowerCase() on every keydown. key is optional on KeyboardEvent, and Chrome proves it: the synthetic keydown it fires when the autofill dropdown opens carries no key at all, so the listener threw Cannot read properties of undefined and took the React tree down with it. It surfaced on Project → Integrations → Platform apps, where the browser offers to autofill the Client Key / Client Secret pair — a form where autofill is both useless and unavoidable, since autoComplete="off" is advisory and Chrome ignores it on password-shaped fields. Now optional-chained, which keeps the condition reading as "is this ⌘K?" rather than adding a separate guard. The IME check two lines below was already defensive about keyCode; this is the same class of missing-property defence one line up. apps/web/src/components/search/search-palette.tsx:65 | — | Claude |
| 2026-09-16 | docs | INTEGRATIONS.md still described platform credentials as organization-scoped, three weeks after F-142 moved them to the project. Every load-bearing detail in the table was wrong: the unique key ((organization_id, provider), actually (project_id, provider)), the route (PUT /channels/credentials/:provider, actually PUT /projects/:projectId/channel-credentials/:provider), the authorization (org:manage, actually assertCanManageProjectIntegrations), the resolver signature, and the UI location (Settings, actually Project → Integrations). The prose beneath it named OAuthPending.organizationId, a field that no longer exists — the signed state carries projectId. schema.prisma was self-contradictory in the same way: the ChannelCredential model's leading comment said "Per-organization" and "organizationId is nullable" while the field comment twenty lines below correctly described F-142, so whichever a reader hit first decided what they believed. Both corrected, plus the rows the table never had: reads need only project membership, nothing is inherited from a sibling project, GET /channels requires project_id, google-analytics is a provider, and TikTok's field is labelled Client Key. No code changed — prisma validate passes and migrate diff reports no drift, because only /// comments moved. | F-142 | Claude |
| 2026-09-16 | feat | The Optimiser joins the team — the ads engine that already shipped is now an agent you can run. campaign_optimisation has been declared in AgentRunKind and listed on the Agents page since E6, and running it hit the default branch of execute.ts and failed with "not built yet". Meanwhile the heuristics have been live on a nightly tick the whole time — CPA doubled week-over-week → pause, spend at the ceiling 4+ days in 7 → +25% daily budget, click-through fading 20% → bid down 10% — writing draft proposals into a table nobody was told about. This wires the two together and adds no new heuristics: a second copy of the rules is how the nightly tick and the on-demand agent quietly start disagreeing about what "over budget" means. Scoped to one project. The tick sweeps every campaign in every org; an agent run joins through adAccount so it can only ever read its own tenant's spend, and draft campaigns are skipped because a campaign that never ran has no spend to optimise. It adopts rather than duplicates. If the nightly tick already wrote today's draft for a campaign, the run links that row instead of creating a second one that would double-count the recommendation — same de-dupe key the tick uses. A proposal a human has already submitted, applied or rejected is left alone, because re-pointing it would rewrite a decision someone took — and neither is a draft a person wrote (submittedByUserId set), since overwriting their rationale with the heuristic's would put words in their mouth. Noted while wiring this up: runDailyGeneratorTick is never called. Its own docblock says main.ts schedules it on a 24h cadence, and nothing does — so the nightly proposals this de-dupes against have never actually been generated in production. Scheduling it is a separate change; the adopt path is cheap insurance for the day it lands. Approving submits; it does not spend. Approval moves each proposal draft → proposed, the ads review flow's own front door, and touches no budget or bid on Google or Meta — the same line the Drafter holds when it creates posts as drafts rather than scheduling them. The approvals screen says so in the one place it matters, since this is the only agent whose approval leads toward money moving. Nothing to do is a result, not a failure. A quiet account, an account with under three days of metrics, and a project with no campaigns each get a real proposal explaining which of those it is — failing the run would make a healthy optimiser indistinguishable from a broken one. | #156 · campaign-optimisation.ts · agents/apply.ts · agent-proposals.ts · agent-roster.ts | Taniya |
| 2026-09-15 | fix | Sender verification emails pointed at a page that did not exist, so no project could ever finish setting up an address. The link built in sender.ts named /verify-sender, and nothing in the web app answered it — every verification email dead-ended in a 404, verifiedAt stayed null forever, and both the live send and the test send kept refusing with a 422 telling people to follow a link that could not work. The API side was complete and mounted the whole time; only the landing page was missing. It now reads the token from the URL, posts it to the public confirm endpoint once, and reports the outcome: the confirmed address by name on success, plus a route back to that project's Integrations tab for whoever set it up — offered rather than assumed, since the mailbox owner is frequently not a Marketing Studio user at all and a bare "Continue" would send a stranger to a login form. Failures name the two ordinary causes (24-hour expiry, single use — so an already-confirmed address lands here too) without the server having to say which applies, because a specific refusal would turn a public endpoint into an oracle for probing which addresses are registered. The request is fired exactly once per mount: the token is consumed on first use, and React's development double-invoke would otherwise spend the link and overwrite a success with "invalid or expired". No backend logic changed. | F-144 | Claude |
| 2026-09-15 | feat | A project can now send through its own Resend account. Project → Integrations → Email sending takes a Resend API key, checks it against Resend before saving it, and shows which domains that account has verified — the sender address has to belong to one of them. From then on that project's broadcasts and test sends go out through its own key, its own DKIM signature and its own sending reputation, while every other project is untouched and keeps using Marketing Studio's mail server (a supported, working state, shown as such rather than as a warning). The key is write-only: encrypted at rest, never returned by any route, never written to the audit trail, and visible afterwards only as its last four characters. Rotating a key takes effect immediately rather than waiting for a process restart, and disconnecting falls cleanly back to the platform transport. Error messages distinguish the cases that have different fixes: a rejected key, an account with no verified domain, and Resend being unreachable. | F-145, D-049 | Claude |
| 2026-09-15 | fix | An owner of one organization could write another organization's project integrations. assertCanManageProjectIntegrations short-circuited on isOrgWideRole(user.role) and never checked which organization the project belonged to, so any owner on the deployment passed the gate for any project id. Some routes were accidentally safe because they happened to call get() afterwards; the channel-credential writes, the project Slack disable/delete, the webhook writes and the Resend account writes acted on the project id directly and were not. Both the assert and its boolean twin now confirm the project is inside the caller's own organization, and answer 404 rather than 403 so a foreign project is not provable by the shape of the refusal. Found by a cross-tenant test written for F-145; pinned by a regression test that exercises each affected write separately, since the bug was precisely that the routes differed in whether something else saved them. | F-142, F-145 | Claude |
| 2026-09-15 | feat | Each project now sends broadcasts from its own address — and Marketing Studio still runs the mail server. Project → Integrations → Email sending takes a sender address, a sender name and an optional reply-to. It asks for no SMTP host, port, username or password, and says so in its first line, because that is the question people arrive with: those belong to the deployment and are the same for every project. The address must be verified before anything real goes out — a link is emailed to it (from Marketing Studio's own address, never from the address being claimed), valid once and for 24 hours. Until it is followed, both the live send and the test send refuse with a 422 that names the state and where to fix it, and the composer can ask …/email-sender/readiness to disable Send before a campaign is even written. Changing the address clears verification; changing just the display name or reply-to does not. Existing email signatures are untouched and still separate — that is the sign-off inside the body, this is who the mail is from — and recipients are still chosen from the project's contacts or typed per broadcast. | F-144, D-048 | Claude |
| 2026-09-14 | feat | Integrations belong to the project now, and each project gets its own Slack channel and webhooks. Platform apps (the Meta/LinkedIn OAuth registrations), Slack and outbound webhooks are configured per project rather than per workspace — an agency running six clients no longer publishes all six through one Meta app, where one client's app review, rate limit or suspension was every client's. Existing apps were copied onto every project in their organization by the migration, so nothing changed on the day it landed; after that the rows are independent, and a project with no app of its own is shown as not configured for this project rather than quietly borrowing a sibling's. Personal Slack and webhooks are untouched and still route your own notifications across every project — Settings is renamed Slack & webhooks and the two surfaces cross-link by name, because the failure this prevents is someone connecting a personal hook and waiting for their team to see publishes in it. Project events fire once per event, not once per person notified: a three-approver workflow posts one card, not three. Managing an integration needs a project lead (it authorizes publishing under the client's name, and on the ads side spend); everyone who can open the project can still SEE integration health, because "the project's Slack is dead" is the answer to "why did nobody see my post go out". Secrets stay write-only throughout — the Slack URL and webhook signing secret are shown once and masked forever after. | F-142, D-046 | Claude |
| 2026-09-14 | fix | Resize stops meaning crop, and is reachable on the image you just uploaded. Two faults, and they compounded: every rendering was fit: cover, so asking for a story out of a 16:9 photo returned the middle third of it with no way to say otherwise — a resize that silently discards a third of the picture is not a resize — and the only way in was the Media library, which is derived from Post.media_assets and therefore cannot show an image until the post carrying it has been saved. The image you most want to resize was the one image you could not reach. POST /uploads/resize now takes fit: fit (the default) keeps the whole picture, crop is the deliberate choice. The padded variant fills the leftover with a blurred, dimmed copy of the image rather than flat bars — flat bars in a feed read as a mistake, which was the original and fair objection to contain; this answers it instead of accepting the crop. An unrecognised fit is a 422 rather than a silent fallback, because handing someone who asked for a crop a padded image is only visible by opening every file, and the fit joins the focal point in the object key so the two renderings coexist instead of one replacing the other. The composer's media tiles get the same crop button, and choosing a size there swaps the attached image in place — appending would publish the story AND the 16:9 it came from, and make the 9-asset cap start counting versions of one photo. The dialog hides the focal-point marker under fit, where nothing is discarded and so there is nothing to choose between. Two smaller things found on the way: the library's resize button was drawn at the same coordinates as the kind badge and buried it on hover, and it was hover-only — a gesture no touch device has, so on a phone the feature had no way in at all. +5 tests. | F-143 | Claude |
| 2026-09-14 | fix | A post composed from the calendar obeyed the same rules as one composed from Content. Opening the composer from the calendar hid the document option that the Content page offered on the very same project. Cause: the calendar handed the composer every social account on the project while Content handed it only isPublishableAccount ones, and targetPlatforms — which is derived from that list — decides far more than the account checkboxes: the media cap, Instagram's post-type control, LinkedIn's visibility control, and whether a document may be attached at all. So one stale disconnected row was enough to make the same project compose two different ways; with LinkedIn live and a dead Instagram row left over, Content computed ["linkedin"] and offered a PDF while the calendar computed ["linkedin", "instagram"] and refused one. An account that could not receive the post was deciding what could go into it. Filtering at each call site was the obvious fix and the wrong one — there are four callers, one had already forgotten, and nothing stops the fifth — so the rule moved into lib/composer-targets.ts and the composer calls it once. One account is deliberately exempt: an account the post being EDITED already targets stays visible even after it disconnects, because dropping it would take its variant off the picker while selectedAccountIds still carried its id — the variant would keep publishing, invisible, with no way to untick something you can no longer see. +10 tests, three of which were confirmed to fail against the old behaviour before the fix was kept. | apps/web/src/lib/composer-targets.ts + test, composer-modal.tsx | Claude |
| 2026-09-14 | feat | Every post now says why it exists, and the project can measure whether the plan is being followed. Six strategic buckets — Education & Expert Advice, Routines & Practical Guidance, Product & Solutions, Community & Engagement, Social Proof & UGC, Brand & Lifestyle — picked in the composer beside Campaign, filterable on the Content page alongside status, type, date and channel, and shown as a chip on every card and row. The classification is the hard part of a feature like this, so the deciding prompt travels with the bucket: the composer's picker and the filter menu both read "Teaching something", "Explaining or promoting a product or service" off CONTENT_BUCKET_META rather than leaving the definitions in a document nobody opens. /projects/{id}/content/mix is the payoff — set a target share per bucket and the page draws the actual distribution against it as a bullet chart, over every post in the project including the unclassified ones, so a project that has bucketed three posts out of fifty cannot read as perfectly balanced. Two deliberate refusals: an unknown ?content_bucket= is a 400 rather than an ignored filter, because answering a filtered request with the full list reads as "everything is in this bucket"; and the column is nullable with no back-fill, because the only available back-fill is a guess and a stored guess is indistinguishable from a real classification. Re-bucketing is content-neutral — it creates no revision and does not invalidate a live approval, so tidying up the mix cannot silently send approved posts back for re-review. The six series colours in the chart are the Okabe-Ito colour-vision-deficiency-safe set, validated rather than eyeballed; everywhere outside that chart a bucket is a neutral chip with a colour dot, because this canvas reserves saturated colour for state. | F-141, D-045 | Claude |
| 2026-09-10 | feat | Resize any library image for the platform it is going to. POST /uploads/resize takes an image and a focal point and returns six shapes - square, portrait, story/reel, landscape, wide and a YouTube thumbnail - and the Media library gets a crop button on every image. The focal point is the feature. fit: cover has to crop, and a centre crop to 9:16 takes the top of a person's head off, which is precisely why people export from a design tool instead of using the product that already holds the photo; clicking the preview to say what stays in frame is a small piece of UI carrying most of the value. Built as its own module rather than by widening ads/creatives/pipeline.ts: that one upserts an AdCreativeAsset row keyed by adCreativeId, so resizing a library photo would have meant inventing an ad creative to hang the output off, and it crops from the centre unconditionally. They share sharp and nothing else. Bytes are posted rather than a URL - an endpoint that fetches a caller-supplied URL is server-side request forgery, reaching an internal address from inside the network with our credentials on the socket. The focal point is part of the object key, so re-cropping from a different anchor cannot silently replace the first render and change URLs already handed out. Animated GIFs are refused rather than flattened to a single frame by the JPEG re-encode, and an unknown size name is a 422 rather than a quietly missing file. | F-143 | Claude |
| 2026-09-09 | fix | Adding someone to one project now actually restricts them — member is no longer org-wide. The rule that decides this (ORG_WIDE_ROLES) was declared eleven times across ads, inbox, posts, scheduling, projects, api-keys, social-accounts, email-broadcasts, campaigns, approvals and analytics, each with a comment pointing at a different sibling as the source of truth. They happened to agree; nothing made them, and the copy that got missed would have failed OPEN. Consolidated into core/org-scope.ts first, with every suite green and no behaviour change, so that the policy edit could be one line. The where fragments stay per-module on purpose — a post scopes through project, a campaign through organizationId plus project, an approval through a post's project — because forcing them into one builder trades a duplicated constant for duplicated joins. Then the one line: member off the list. Three things had to move with it. assertCanWriteProject now tracks the same list rather than its own, because a project you cannot open must not be one you can PATCH — read and write scope disagreeing is how "404 on GET, 200 on PATCH" happens; project CREATION keeps its own allow-list, since staff making their own work is the normal case. Creating a project writes a lead membership for the creator inside the create transaction, or a member would have created a project and watched it vanish on the redirect. And a backfill gives every existing member a contributor row on every project in their org — contributor because they could edit any project the day before and a migration is the wrong place to take that away, ON CONFLICT DO NOTHING so an existing lead is not flattened. | F-119 | Claude |
| 2026-09-09 | fix | An agency invited onto one project could read and rewire every other project's analytics. analytics/routes.ts::projectInScope took a bare organization id — it never received the user, so it could not consult project membership even in principle, and all 8 of its route handlers inherited that. Every other module had been enforcing membership for years; this one had a signature that made it impossible. Now takes the user and delegates to the same projectScope the KPI routes use. Out of scope stays 404 rather than 403, because a 403 confirms the project exists. | F-119 | Claude |
| 2026-09-09 | fix | Product Analytics served invented numbers with nothing on screen to say so. The GA4 and Search Console adapters fall back to a deterministic mock when ANALYTICS_MOCK_MODE=1 or when the Google credentials are simply absent — and the second case needs no deliberate act to reach, so a workspace that never finished the Google setup was reading fabricated pageviews off a dashboard that looked exactly like a measured one. The only trace was a line in the server log and a (mock) suffix on a display name nothing rendered. Adapters now declare isMock, the connection API returns it, and the connections screen reports the row as sample data rather than a healthy green connection. Declared on the adapter rather than re-derived from config at the call site, so the answer comes from the object that actually produced the numbers. | F-119 | Claude |
| 2026-09-09 | feat | Invite someone to one project, from the invite screen, and have it mean something. The invitations table has carried a project_id since it was built, the domain validated it against the org, and the accept path already created the ProjectMember row — but the invite modal only ever sent an email and a role, so there was no way to reach any of it. The modal now asks the question first: the whole workspace or one project only, then a project and a project role. ProjectMember.role is chosen rather than assumed — accept hardcoded contributor for everyone, so inviting a read-only reviewer meant demoting them afterwards on a second screen; a new nullable Invitation.projectRole carries the choice and falls back to contributor for invitations minted before the column. The important half is a refusal: a project-scoped invite with an org-wide role is rejected outright (422 from the schema, 400 from the domain, which is reachable from a worker the route parser is not). projectScope skips its membership clause entirely for owner, admin and member, so scoping a member to one project would have added the membership row and then shown them every other project anyway — the form would have promised isolation the server does not implement. The role dropdown narrows to agency/client the moment a project is chosen, rather than offering a choice the API will reject. The invitations table gains an Access column, because two invitations with the same role can reach completely different amounts of the workspace. Separately, the project members screen stops asking for a raw UUID — it now picks from the workspace directory, minus whoever is already on the project; nothing in the product ever displayed a user id, so that field could only be filled by going and looking one up in the database. | F-118 | Claude |
| 2026-09-09 | feat | A broadcast can go to addresses that are not in any segment. The To field's picker gains Custom Recipients beside the three built-in segments; choosing it swaps the count for a chip field where addresses are typed or pasted. Comma, semicolon, newline and tab all separate (a list copied out of Outlook is semicolon-separated, one out of a spreadsheet is newline-separated); Enter commits, Backspace on an empty field removes the last chip, every chip has a labelled remove button, and blur commits so an address does not vanish because Send was clicked before Enter. Addresses are trimmed, lowercased and de-duplicated, so Ada@x.com and ada@x.com are one recipient rather than two copies of the same mail. A typo is named (\"oops\" is not a valid email address) and only the typo stays in the field — the valid siblings have already become chips, and leaving the whole string would show each address twice with nothing to say which one the send would use. Send is disabled with an empty list, and the empty-state warning is its own sentence: an empty segment is a data problem ("import a list"), an empty typed list is just a field nobody has filled in. Server-side the list is validated and re-normalised — the client de-dupes for the count, the server de-dupes because it cannot trust the client, and two implementations of "the same list" is how the UI promises 3 recipients and the send writes 4 rows. Typed addresses still pass the suppression check: a hand-typed address is exactly how somebody who unsubscribed gets mailed again, and "I typed it myself" is not a lawful basis, so an address matching an unsubscribed contact is dropped and an all-suppressed list refuses rather than reporting a successful send to nobody. A typed address that IS a subscribed contact links to it, so merge tags still resolve. Capped at 50 — a real list belongs in a segment, where it can be unsubscribed from — and the send route now carries a per-IP ceiling, because an endpoint that mails addresses named in the request is a relay. Deliberately exclusive with a segment rather than additive: audience_key, audience_label, the count and the history chip are all single-valued, and a broadcast holding both would answer "who was this sent to" with two answers. | email-broadcasts.ts · recipient-chips.tsx · F-111 | Claude |
| 2026-09-09 | fix | The thumbnail fix shipped correct and stayed invisible — the cached context outlived the rules that produced it. Every inbox item already carried a postContext resolved under the old rules (thumbnail_url: null), and a cache hit meant the new code never ran: right in the repository, broken in production, which is the worst way for a change to fail. The cached value now carries the version it was resolved under, and an entry written under an older one is ignored and re-resolved once, then re-stamped — entries from before this existed have no version at all and are stale by definition, which is exactly right. The list serializer applies the same test and reports a stale context as absent rather than passing it on: the client seeds its query with that value and holds it indefinitely (staleTime: Infinity), so handing over a stale one would pin the old answer on screen with nothing left to trigger a refetch. The version is bookkeeping and never reaches a client. Bump CONTEXT_VERSION whenever the shape or the rules change; that is now the whole procedure. | F-046 · inbox/post-context.ts · inbox/serialize.ts | Claude |
| 2026-09-09 | fix | The post panel showed a grey placeholder instead of the picture. The studio path — a post matched locally against our own PlatformPost — set thumbnail_url: null outright, because PlatformPost has no thumbnail column. But the media is ours and was already stored: the variant's platformMedia and the post's mediaAssets both carry a url. The panel now selects from them by the same rule every other screen uses (web/src/lib/post-media.ts): variant media first, since that is what the publisher actually sent, then the post's own assets, first asset either way, video included. Kept identical deliberately — three lists once had three answers to this and the same post was represented by three different pictures depending on the screen, which is worse than showing none, and this panel must not become a fourth rule. When a studio post genuinely has no local asset, only the picture is borrowed from the platform; the caption stays ours, because platformCaption is what was actually published and the platform's echo can differ. That borrow is non-fatal and only fires when something is missing, so the ordinary studio path still costs no API call. | F-046 · inbox/post-context.ts | Claude |
| 2026-09-09 | feat | A comment in the inbox now shows the post it is on. With Meta delivery working (B-017), the pane said only that @someone had written "Great" — which is not enough to reply to, and sending the reader to Instagram to find out defeats the point of having an inbox. The parent id was already stored on every comment (InboxItem.parentExternalId, the media id) and already serialized; what was missing was anything human could read. New GET /inbox/:id/post-context resolves it from two sources, cheapest first. Our own post matches locally: Instagram's publish returns the media id and it is stored verbatim as PlatformPost.platformPostId, so a post published from this studio resolves by equality with no API call, no token, and the caption the author actually wrote — the per-platform platformCaption where one exists, since that is the text the audience saw. Everything else costs one getPostPreview — a new OPTIONAL adapter verb implemented for Instagram and Facebook Pages, fetching caption, permalink, thumbnail and timestamp. Deliberately its own endpoint rather than a field on the list: resolving a post we did not publish costs a Graph call, and filling it for every row would spend fifty of them to render a panel that shows one. The UI asks when an item is opened; the result is cached into the existing platform_extra JSON (no migration) so the second look is free, and is surfaced as post_context on the item thereafter. Failure is never fatal — the reply box is the point of that screen — so a refusal answers 200 with the reason and the post's external id, which is what someone would paste into the platform to find it by hand; and a failed resolve is retried rather than cached, because the usual cause is a token that has since been reconnected. A DM answers null: no parent post is an answer, not an error. | F-046 · inbox/post-context.ts · channels/adapters/{instagram,facebook}.ts | Claude |
| 2026-09-09 | fix | Third and final attempt at the Instagram webhook subscription — the node was wrong, not just the host. #237 moved the call off graph.instagram.com (which answered 190 Cannot parse access token, since it wants an Instagram User token and a Facebook-Login connection only ever holds a Page token) onto graph.facebook.com — but pointed it at the IG user id, which answered (#3) Application does not have the capability to make this API call, because subscribed_apps is not an edge of that node. The Facebook-Login docs describe the Page node: installing the app on the Page is what makes Instagram events for the linked account flow. The adapter now tries every documented shape in order — Page node with the Instagram fields, Page node with a plain feed install if Meta rejects those as not-Page-fields, then the graph.instagram.com form last so an app later reconfigured for Instagram Login keeps working — and records which attempt was accepted, host and id and fields, in webhookSubscription.target. Three deploys were spent on this because each wrong edge failed in a way that looked like the others, and because the first version reported a bare 400 without naming the host that produced it; the error now quotes every attempt, which is the change that would have made this one round trip instead of three. Shipped alongside two changes that stop the next failure from needing a deploy to diagnose. The webhook account lookup now matches provider_meta.pageId and igUserId as well as accountPlatformId: the Instagram subscription is made against the PAGE node, so Meta may key a delivery by the Page id while the connection is stored under the IG User id, and that mismatch silently dropped correct deliveries. And the two remaining silent paths log once at warn — an entry.id matching no managed account prints that id, and a batch that verifies and parses but writes nothing prints its event count. Neither is an error, which is why both were silent; but from outside, "we dropped it" and "Meta never sent it" were the same observation — an empty inbox and a 200 that Meta's own dashboard reports as a successful delivery. | B-017 · channels/adapters/instagram.ts · adapters/meta-webhooks.ts | Claude |
| 2026-09-09 | fix | Every Instagram story published as a feed post, and nothing in the app could say otherwise. The adapter has had a story branch since it was written — media_type: STORIES, keyed off content.postType === "story" — and it was unreachable code. The publisher derived postType from the media alone (`video | image | article |
| 2026-09-09 | feat | A post's history is readable from the post, and the activity feed links to the post it is about. The trail already held every event — it was only reachable as a project-wide table of raw action strings (post_submitted_for_approval, agent_run_applied) keyed by an id nobody has to hand, so reconstructing one post's life meant reading rows about every other post. Two changes close that. (1) Activity rows about a post carry a Go to post action that opens the existing post detail route — no new page. post rows name a Post, but platform_post rows name a channel variant, a different table, so the parent is resolved through the project's own posts list rather than by treating the id as a post id or digging one out of meta, which is written by the caller and is not a foreign key. A post that has since been deleted resolves to nothing and renders no link, instead of one that 404s. (2) A Post timeline on the post detail and insights pages, over one new GET /audit-logs?post_id= filter that resolves the post's variants and its approval server-side, org-scoped exactly as the rest of that route is. Every row reads as a sentence with its actor, role, channel and an expandable diff/metadata view. Three trail faults surfaced while building it: the publisher's publish / publish_failed rows were written with organizationId: null, which made every scheduled publish invisible to an API that scopes by org — the one event people most want to see; a post edit recorded no project_id, so no edit ever reached the project's own activity feed; and the auto-schedule that fires on approval recorded nothing at all, leaving a history that jumped from approved to published with nothing to say what had queued it. Status moves now carry lifecycle names (post_scheduled, post_published, post_publish_failed) instead of a flat update whose meaning only meta.status held; older generic rows still render correctly, since the UI labels both. | audit/routes.ts · post-activity.ts · post-timeline.tsx | Claude |
| 2026-09-09 | fix | The webhook subscription shipped that morning could never have succeeded — wrong host. subscribeWebhooks called graph.instagram.com/{page-id}/subscribed_apps, following Meta's own Instagram subscribed_apps page. That page documents Instagram API with Instagram Login, whose access token is an Instagram User token. This adapter is Instagram API with Facebook Login end to end — facebook.com/dialog/oauth, graph.facebook.com, IG reached through the Page's instagram_business_account edge — so the only token it can present is a Facebook Page token, and graph.instagram.com answered every call with OAuthException code 190, "invalid access token". The doc was right; it was describing a different product. Every reconnect since the deploy silently recorded webhookSubscription.ok = false and no account was ever subscribed. The adapter now tries both edges in order — graph.facebook.com/{ig-user-id} first, since that matches its own flavour, then graph.instagram.com/{page-id} — and records which one accepted in webhookSubscription.target, so the host question is answered by data rather than rediscovered. Ordered rather than raced: a subscription on the wrong edge is not free to undo. When every edge fails, the error now names each host and quotes what it said; the original failure surfaced as a bare 400 with no indication of which host produced it, which is what made this cost a round trip through production to diagnose. Caught only because POST /channels/:id/resubscribe — added in the same batch — reports the platform's refusal synchronously instead of leaving it in a column nobody reads. | B-017 · channels/adapters/instagram.ts | Claude |
| 2026-09-09 | feat | Threads and Facebook join Instagram on measured audience breakdowns — and Facebook's age/gender panels are retired rather than faked. Threads runs Meta's insights stack under a different hostname, so follower_demographics works there too — all four cuts, same 100-follower floor, but a different call: Instagram's metric requires period + timeframe and Threads' takes neither and rejects since/until, so copying Instagram's params would 400. Needs threads_manage_insights, now added to analyticsOnlyScopes and requested on the authorize dialog; existing connections predate it, keep working, and show the empty state until reconnected. Facebook is the interesting one: page_fans_country and page_fans_city are still listed in Page Insights and still answer, but page_fans_gender_age is gone — Meta cut age/gender for Pages connected after March 2024 and the June 2026 batch finished it. So Facebook reports two dimensions, in ONE request (Page Insights takes a comma-separated metric list; no cross-tab constraint forces Instagram's four calls) with period=day (v3.2 moved page_fans_* off lifetime, which now returns an empty dataset that would look exactly like a Page with no fans) and reading the LAST value in the series, not the first, or the count is up to three days stale. read_insights was already requested, so no reconnect. The Fan age and Fan gender panels no longer fall back to sample shapes: they render empty carrying Meta's own reason, because an illustrative shape implies the numbers are merely uncollected and someone will get to them — the opposite of true. audienceUnavailableReason is now keyed by dimension as well as platform to carry that distinction. The denominator rule is dimension-aware too: when a collection carries no stored total, age and gender fall back to the bucket sum (they partition the audience) while country and city refuse and report unavailable (they are truncated, so the sum would restate a partial breakdown as full coverage). Audience history rides the existing retention tick but always keeps the newest collection per account and dimension, however old - otherwise a disconnected account's panel would go blank the day its last snapshot aged out. Still unreachable: LinkedIn (its follower statistics are an organization API and this app's adapter is a personal profile — a Company Pages adapter is its own feature), Pinterest (audience insights live under ad_accounts/{id}, which we don't connect), X (no such API), TikTok (country only; age/gender are absent from the official API). | F-117 · F-062 | Claude |
| 2026-09-09 | fix | The Inbox could never have received an Instagram comment or DM — two separate faults, both silent. Engagement arrived on the platform and nothing reached the app, with no error at any layer: the webhook verified, the Meta dashboard read Subscribed, and every delivery answered 200. (1) Nothing was ever delivered. Ticking comments in the app's webhook settings only declares which fields the app wants; Meta additionally requires a per-account subscription — POST /{page-id}/subscribed_apps — before it sends a single event. That call did not exist anywhere in this repository, so no account had ever been subscribed. New OPTIONAL subscribeWebhooks on ChannelAdapter, implemented for both Meta adapters and run at the end of every OAuth connect. Three details of it are each able to fail quietly and are now pinned by tests: an Instagram professional account subscribes on graph.instagram.com, not graph.facebook.com (whose twin path subscribes Page events — a different subscription); the id in the path is the Page id, not the IG user id; and the token is the Page token, never the user token. Failure is deliberately non-fatal to connecting — a channel that publishes but has no inbox is degraded, not broken — and the reason is written to provider_meta.webhookSubscription rather than to lastError, which drives the "reconnect this account" banner and a missing inbox is not a dead token. POST /channels/:id/resubscribe re-runs it for accounts connected before this existed. (2) A delivered Instagram comment would have been dropped anyway. meta-webhooks.ts handled field: "comments" with Facebook's feed payload shape, requiring verb === "add" and item === "comment" and reading value.comment_id / value.message. Instagram sends none of those keys — no verb, no item, the id under id, the body under text, the author under from.username — so the first guard returned null on every real comment, and logged nothing, because "not an event we care about" is the ordinary path. The two shapes are now normalised; Facebook's edit/delete filter still applies only to the payloads that carry a verb. The suite had been green throughout because its fixture sent the Facebook shape inside an object: "instagram" envelope: it proved the mapper matched the fixture, not that the fixture matched Meta. New tests use the real payload. Batched IG comments also shared one synthetic event id, since comment_id was absent. (3) The permissions were never requested. New inboxScopes bucket on AdapterCapabilities — kept apart from requiredScopes for the reason analyticsOnlyScopes is: an app whose dashboard has not granted them can still connect, publish and report. pages_manage_metadata (without it the subscribe call answers (#200) Requires pages_manage_metadata permission) and instagram_manage_messages, without which Meta will not deliver the messages field at all. Facebook Page DMs are deliberately left off: they need pages_messaging, which belongs to Messenger — a capability neither of this app's use cases includes, so the permission is not offerable. Requesting it anyway would not degrade gracefully, it would break Facebook connect entirely, since the authorize dialog rejects the whole request with Invalid Scopes for a permission the app cannot grant; and subscribing to the messages field without it fails subscribed_apps as a whole, taking Page comments down with the DMs. So the Page subscribes to feed only, and re-enabling is a two-constant change documented in adapters/facebook.ts. Instagram comments and DMs are unaffected. ⚠️ Existing connections must be reconnected — a permission added to an app grants nothing to a token already issued. | B-017 · meta-webhooks.ts · channels/adapters/{instagram,facebook}.ts · channels/routes.ts | Claude |
| 2026-09-08 | feat | Instagram audience demographics are measured, not illustrative. The age, gender and city panels on the Instagram dashboard were drawn from lib/analytics-sample.ts behind an amber Sample data chip — honest, but the numbers were fabricated while Instagram had been handing them out the whole time. instagram_manage_insights, the scope follower_demographics needs, has been requested on every connect since the post-insights work; what was missing was the call. New OPTIONAL getAudienceDemographics on ChannelAdapter (optional for the same reason deletePost is — X exposes nothing, and Meta is actively withdrawing the equivalent for Facebook Pages), implemented for Instagram as four one-dimensional calls rather than one cross-tabbed breakdown=age,gender. Stored in a new AccountAudienceSnapshot — rows not columns, so a future LinkedIn seniority cut is an enum member rather than a migration, and label stays platform-native because normalising F to Women at write time destroys the original with no way back. Keyed by fetchedAt, deliberately NOT by UTC day: this is a lifetime snapshot of the current follower base, there is no backfill and no range scoping, which is also why it stays out of the pivot — ProjectKpiSnapshot is day × channel × platform and these rows have no day. Percentages are shares of the real follower count, never of the bucket sum: Instagram truncates geography to the top 45, so renormalising would report a partial breakdown as 100% coverage. Polled on its own 24h cadence rather than the 6h account tick — the numbers barely move and each run costs four Meta calls per account. Under Instagram's documented 100-follower floor the adapter skips the insights calls entirely, so an empty panel says "Instagram reports demographics only for accounts with 100 or more followers" rather than rendering blank. sampleCitySplit lost its last caller and is deleted per the module's own rule; the age/gender/geo shapes survive for the seven platforms still without ingestion. | F-062 · F-117 | Claude |
| 2026-09-08 | fix | The Marketing Scout was reading a column nothing ever writes, so it reported "0 posts published" on every run, forever — and the agents now read the studio for real. runMarketingScout queried Post.publishedAt. No code path in this repository sets that column: the publisher stamps PlatformPost.publishedAt on the variant it actually pushed, and metrics, KPI rollups, account analytics and the posts_published usage meter all read the variant. The scout was its sole reader, so posts.length was structurally 0 on every deployment no matter how much had been published; every run fell into the cold-start branch and returned the same two hardcoded sentences with no model call at all. The output looked like mock data because it was a constant. Its test passed because the fixture seeded Post.publishedAt directly — a state production cannot reach — so it proved the fixture matched the query, not that the query matched reality. (1) The query is fixed. A new agents/kinds/project-context.ts reads published variants like the rest of the codebase, taking the platform caption the audience actually saw over the base caption. (2) Metrics reach the prompt. Each post carries its newest PostMetricSnapshot — impressions, engagement, engagement rate — so "what is working" rests on numbers instead of on caption prose. A post with no snapshot is labelled no metrics collected yet, because absent data and poor performance are different findings and the model was previously free to conflate them. (3) A month of unpublished work is no longer invisible. When nothing is published, the scout reviews the drafts, scripts and scheduled posts in the studio and says so, rather than reporting "no history" to someone looking at thirty posts they wrote. Cold start now fires only when there are no published posts and no drafts. (4) The brief reaches both agents. name, description, industry, websiteUrl and channels were all on Project and none had ever reached a prompt. Even the cold-start text is now project-specific. (5) The Ideator's notebook is real. WINNERS_LIST and RULE_LEDGER were two arrays of invented examples — posts about onboarding checklists and Friday deploys, and three rejections nobody had made. A code comment said they were fake; the model could not read the comment, so it matched their subject matter, which is why ideas came back written for somebody else's company. Winners now come from the project's own top posts by measured engagement, and the rejection ledger from the decisionNote humans write at the approval gate — the field already made mandatory ("the next run needs the reason") and until now read by nobody. The old arrays survive only as a labelled fallback for a project with neither, and are never presented to the model as "ours". (6) Every idea was targeted at LinkedIn and X, whoever you are. normaliseChannels fell back to a hardcoded ["linkedin", "x"] whenever the caller named no channels — and the Agents page starts a run with { scoutRunId } and nothing else, so that constant fired on every ideation run in the product. An Instagram-and-TikTok brand got ideas written for two platforms it has never had an account on, with the channel names printed on the proposal being read as a decision. Channels now come from the project's connected social accounts; the constant is reached only when there are none, and the run says so. The hand-written allow-list had also drifted from the SocialPlatform enum — it omitted pinterest and whatsapp and carried twitter, which is not a platform — so a Pinterest-only project had its own channel filtered out and fell through to the constant. It is now derived from the enum, with twitter kept as an alias for x. (7) Runs are costed. AgentRunStep.usage has always been documented as where a run's cost becomes attributable and held only a string length. AiProvider.chat gained an onUsage callback (non-breaking, same shape as the publisher's onFetchAudit), and every llm_call step now records provider, model, prompt/completion tokens and an estimated cost in integer micro-USD from a price table in modules/ai/pricing.ts. All three agents in the chain report through one recordLlmUsage — the Drafter included, which at 2400 output tokens is the most expensive call and would have left a full chain unmeasurable at the step that dominates its price. The Ideator sums across its corrective retry, so a run that retried is not reported at the price of one call. An unpriced model reports null, never 0. (8) The approvals screen shows the evidence — "Analysed 12 published posts across linkedin, instagram — 9 with performance data" — so "found nothing" is finally distinguishable on screen from "did not look". | #156 · marketing-scout.ts · marketing-ideate.ts · project-context.ts · ai/pricing.ts · agent-proposals.ts | Taniya |
| 2026-09-04 | feat | Social Alchemy and Marketing Alchemy — the report builders. A pivot over the KPI stores: pick up to two breakdowns and up to six metrics, get a bolded total with its groups nested underneath, and take the whole thing as CSV. One screen serves both scopes; only the vocabulary differs, so organic groups by date/platform/asset while paid groups by date/campaign/network/objective. New GET /projects/:id/kpis/pivot plus a /catalogue companion that reports which fields this project can actually source — the UI hardcodes no field list, so a future column turns its checkbox on with no frontend change. Aggregation happens in the process rather than in SQL, because the ratios have to be right: CTR over a range is Σclicks / Σimpressions, and a GROUP BY … AVG(ctr) would ship the mean of daily rates, which for a 1%-day and a 3%-day reports 2% instead of the true 2.5%. null and 0 stay distinct end to end, so a platform that reported nothing never averages in as a zero. Every field the design shows is rendered, including the ones with no data behind them — disabled, tagged n/a, with the reason as the tooltip, and refused server-side with a 422 rather than answered with a column of nulls that reads as a real measured zero. Social can source Date/Platform/Asset and six metrics; Marketing can source Date/Campaign/Network/Objective and nine including CTR, CPC, CPM and CPA. The rest are declared and off: no post records a format, no demographics are collected, ad metrics arrive keyed by campaign and day so ad-set/creative/placement are unavailable, and revenue is attributed per project rather than per campaign so ROAS cannot be divided out. | F-062 · F-061 | Claude |
| 2026-09-04 | feat | The analytics screen is rebuilt on the reviewed dashboard design. Ported scoped-first: every token lives under a .analytics-canvas class in globals.css rather than :root, so the look can be judged on one route before it restyles the app. Cards move to a 14px radius with a hairline border and a one-pixel lift; each KPI tile now reads label + delta, then the figure at 25px in mono, then a plain-language note on what it counts — the sparkline sits beside the number rather than under the note, which squeezed it to half its glyphs at three-up. Tiles are explicit lg:grid-cols-3 tracks, not auto-fit: auto-fit divides by the minimum card width and rendered six tiles as a ragged 4+2. The trend chart moves from a fixed 720×260 hand-rolled polyline to Recharts (via a small hand-written ui/chart.tsx — the shadcn CLI is not initialised here and init would rewrite globals.css wholesale), gaining resize, a themed tooltip, and a faded area. ChannelSplit becomes a trio whose third card is impression share by platform: the design's Age/Gender/Geography cards assume demographics this product does not collect, so the shape is reused for something true rather than filled with an invented metric. Tables adopt the design's uppercase micro-heads and mono right-aligned figures. | F-062 | Claude |
| 2026-09-04 | fix | Four charts were painting a black silhouette instead of a trend line. --primary holds a complete colour (#2563eb), not the bare HSL channels shadcn's docs assume, so hsl(var(--primary)) substituted to hsl(#2563eb) — invalid at computed-value time. Because fill and stroke are inherited properties the declaration did not simply fail: it resolved to unset, giving an opaque black fill and a none stroke. Confirmed in a browser: the area polyline computed to rgb(0,0,0) and the line to none. Fixed in the analytics trend chart, performance/seo/share-of-voice-chart.tsx, performance/seo/rankings-tab.tsx and components/organic/follower-growth-chart.tsx — bare var(--primary), and color-mix() where an alpha was wanted. Nothing in typecheck, lint or build can see this, so src/lib/design-tokens.test.ts scans the source tree for the pattern and fails on it. | F-062 · F-060 | Claude |
| 2026-09-04 | feat | The Ideator stops asking for "ideas" and starts asking for arguments — Phase 1 of the ideator pipeline. An idea used to be { title, angle, hook, why }: a label, a line, and a free-form paragraph in which the model argued for its own suggestion. Nothing in that shape carries the thinking — what is the claim, what backs it, what does the reader do — so it arrived at the Drafter instead, one step after the human who had to approve it. (1) Fixed structure. An idea is now a claim chain: title, hook, coreArgument, proof, takeaway, channels, every field required. content-draft.ts reads the new fields and passes all three of argument/proof/takeaway into the drafting prompt — the two files have to move together or the Drafter silently writes copy from empty strings. (2) The prompt asks for a kind of thinking, not just a shape. One assigned creative lens per idea (contrarian takedown · incident teardown · expensive lesson · definition fight), cycling through the batch, so variety is constructed rather than requested; five constraints stated as pairs, because a bare "no listicles" leaves the model to invent the alternative and it invents from the same register the ban was escaping; and a hardcoded winners list and rejection ledger standing in for the shared notebook — labelled as craft references, explicitly not this client's own posts, so the model cannot cite them back as ours. (3) Rules are enforced in code. screenIdeas re-checks everything mechanical after the model returns: hook under 140 characters (the mobile fold — a hook the feed truncates is not the hook the approver read), all fields present, no banned vocabulary (unlock, delve, synergy, tapestry, …), no channel the human didn't pick. Every drop is attributed to the rule that caused it. (4) A drop rate you can watch. Generated / survived / drop-rate / per-rule counts go to the run's guardrail_filter step and to stdout — the step answers "why is this run thin" for one run, the log answers "is the prompt getting better" across all of them, which is the number this phase exists to produce. A batch that survives nothing is retried once, carrying the exact rules that fired, then fails cleanly with them in the error rather than proposing a thin batch for someone to rubber-stamp; a provider transport error is not retried here, since that is the runner's job. Phase 1 adds no store, embeddings or tables. +8 tests (guardrail attribution, the 139/140 boundary, word-boundary banned matching, multi-rule reporting, retry-then-succeed, retry-then-fail, non-JSON recovery, no-retry-on-provider-error). | apps/api/src/modules/agents/kinds/{marketing-ideate,content-draft}.ts, apps/web/src/lib/agent-proposals.ts, docs/plans/2026-08-20-studio-agents.md | Claude |
| 2026-09-04 | feat | Social Alchemy and Marketing Alchemy — the report builders. A pivot over the KPI stores: pick up to two breakdowns and up to six metrics, get a bolded total with its groups nested underneath, and take the whole thing as CSV. One screen serves both scopes; only the vocabulary differs, so organic groups by date/platform/asset while paid groups by date/campaign/network/objective. New GET /projects/:id/kpis/pivot plus a /catalogue companion that reports which fields this project can actually source — the UI hardcodes no field list, so a future column turns its checkbox on with no frontend change. Aggregation happens in the process rather than in SQL, because the ratios have to be right: CTR over a range is Σclicks / Σimpressions, and a GROUP BY … AVG(ctr) would ship the mean of daily rates, which for a 1%-day and a 3%-day reports 2% instead of the true 2.5%. null and 0 stay distinct end to end, so a platform that reported nothing never averages in as a zero. Every field the design shows is rendered, including the ones with no data behind them — disabled, tagged n/a, with the reason as the tooltip, and refused server-side with a 422 rather than answered with a column of nulls that reads as a real measured zero. Social can source Date/Platform/Asset and six metrics; Marketing can source Date/Campaign/Network/Objective and nine including CTR, CPC, CPM and CPA. The rest are declared and off: no post records a format, no demographics are collected, ad metrics arrive keyed by campaign and day so ad-set/creative/placement are unavailable, and revenue is attributed per project rather than per campaign so ROAS cannot be divided out. | F-062 · F-061 | Claude |
| 2026-09-04 | feat | The analytics screen is rebuilt on the reviewed dashboard design. Ported scoped-first: every token lives under a .analytics-canvas class in globals.css rather than :root, so the look can be judged on one route before it restyles the app. Cards move to a 14px radius with a hairline border and a one-pixel lift; each KPI tile now reads label + delta, then the figure at 25px in mono, then a plain-language note on what it counts — the sparkline sits beside the number rather than under the note, which squeezed it to half its glyphs at three-up. Tiles are explicit lg:grid-cols-3 tracks, not auto-fit: auto-fit divides by the minimum card width and rendered six tiles as a ragged 4+2. The trend chart moves from a fixed 720×260 hand-rolled polyline to Recharts (via a small hand-written ui/chart.tsx — the shadcn CLI is not initialised here and init would rewrite globals.css wholesale), gaining resize, a themed tooltip, and a faded area. ChannelSplit becomes a trio whose third card is impression share by platform: the design's Age/Gender/Geography cards assume demographics this product does not collect, so the shape is reused for something true rather than filled with an invented metric. Tables adopt the design's uppercase micro-heads and mono right-aligned figures. | F-062 | Claude |
| 2026-09-04 | fix | Four charts were painting a black silhouette instead of a trend line. --primary holds a complete colour (#2563eb), not the bare HSL channels shadcn's docs assume, so hsl(var(--primary)) substituted to hsl(#2563eb) — invalid at computed-value time. Because fill and stroke are inherited properties the declaration did not simply fail: it resolved to unset, giving an opaque black fill and a none stroke. Confirmed in a browser: the area polyline computed to rgb(0,0,0) and the line to none. Fixed in the analytics trend chart, performance/seo/share-of-voice-chart.tsx, performance/seo/rankings-tab.tsx and components/organic/follower-growth-chart.tsx — bare var(--primary), and color-mix() where an alpha was wanted. Nothing in typecheck, lint or build can see this, so src/lib/design-tokens.test.ts scans the source tree for the pattern and fails on it. | F-062 · F-060 | Claude |
| 2026-09-07 | feat | A studio assistant you can ask, instead of a pipeline you have to already understand. The agent chain gates each step on the previous one's approval, and the only thing that says so is a greyed-out button. Adds a multi-turn assistant that knows the product — the manual composer route as well as the Scout → Ideator → Drafter chain, which agents are not built, that approval is a per-project setting off by default — and is handed the project's real channels and recent runs so it answers about your workspace rather than about social media in general. It ADVISES AND NEVER ACTS: it cannot start a run, publish or spend, because every other path routes a decision through Approvals and an assistant that could act would be the one way round that gate. The thread is stored server-side (chat_conversations + chat_messages): the client sends one message and a conversation id, and the server assembles the transcript from what it wrote. The first cut replayed the thread from the browser, which was wrong three ways — a caller could claim the assistant had previously agreed to something, the cost of each message grew with the thread above it, and closing the panel lost the conversation. A stored turn can only be user or assistant; system is not a storable role, so a transcript can never carry instructions back into the model. | POST /ai/chat · ai/chat.ts | — |
| 2026-09-04 | feat | The Ideator stops asking for "ideas" and starts asking for arguments — Phase 1 of the ideator pipeline. An idea used to be { title, angle, hook, why }: a label, a line, and a free-form paragraph in which the model argued for its own suggestion. Nothing in that shape carries the thinking — what is the claim, what backs it, what does the reader do — so it arrived at the Drafter instead, one step after the human who had to approve it. (1) Fixed structure. An idea is now a claim chain: title, hook, coreArgument, proof, takeaway, channels, every field required. content-draft.ts reads the new fields and passes all three of argument/proof/takeaway into the drafting prompt — the two files have to move together or the Drafter silently writes copy from empty strings. (2) The prompt asks for a kind of thinking, not just a shape. One assigned creative lens per idea (contrarian takedown · incident teardown · expensive lesson · definition fight), cycling through the batch, so variety is constructed rather than requested; five constraints stated as pairs, because a bare "no listicles" leaves the model to invent the alternative and it invents from the same register the ban was escaping; and a hardcoded winners list and rejection ledger standing in for the shared notebook — labelled as craft references, explicitly not this client's own posts, so the model cannot cite them back as ours. (3) Rules are enforced in code. screenIdeas re-checks everything mechanical after the model returns: hook under 140 characters (the mobile fold — a hook the feed truncates is not the hook the approver read), all fields present, no banned vocabulary (unlock, delve, synergy, tapestry, …), no channel the human didn't pick. Every drop is attributed to the rule that caused it. (4) A drop rate you can watch. Generated / survived / drop-rate / per-rule counts go to the run's guardrail_filter step and to stdout — the step answers "why is this run thin" for one run, the log answers "is the prompt getting better" across all of them, which is the number this phase exists to produce. A batch that survives nothing is retried once, carrying the exact rules that fired, then fails cleanly with them in the error rather than proposing a thin batch for someone to rubber-stamp; a provider transport error is not retried here, since that is the runner's job. Phase 1 adds no store, embeddings or tables. +8 tests (guardrail attribution, the 139/140 boundary, word-boundary banned matching, multi-rule reporting, retry-then-succeed, retry-then-fail, non-JSON recovery, no-retry-on-provider-error). | apps/api/src/modules/agents/kinds/{marketing-ideate,content-draft}.ts, apps/web/src/lib/agent-proposals.ts, docs/plans/2026-08-20-studio-agents.md | Claude |
| 2026-09-04 | fix | "This account can't pay" is said where the campaigns are, not only while connecting one. #209 built the check and wired it to a single caller: the OAuth account picker. That is the one moment it cannot help — an account is not broke on the day it is connected — and nothing was stored, so a connected account was never asked again. The Meta Ads hub showed Connected and a Sync button over an account Meta would refuse. The state is now refreshed on sync and by the metrics poll (6-hour floor), kept in AdAccount.providerMeta, and rendered full-width under the hub header with Meta's own fix-it link and the time it was checked. Best-effort throughout: a billing read that fails leaves the previous snapshot alone rather than costing the user the import they actually asked for. | F-051 · F-114 | Claude |
| 2026-09-03 | fix | The feature tracker said planned for twenty things that are live. LinkedIn, Facebook and Instagram publishing, the OAuth connect flow, the unified inbox, inbound webhooks, the publisher, scheduling, approvals, the client portal, notifications, GA4, scoped API keys and the AI composer all shipped and none of the rows moved — so the board read 40 done / 58 planned while the product read otherwise, and anyone quoting it was quoting fiction. Corrected against the code: seventeen rows to done, three to in_progress where part is genuinely missing (Google Business has no adapter; the KPI roll-up and the report builder are partial). Every corrected row carries an updates entry naming the module and the test suite that back the claim, so the next reader can check it rather than trust it. Now 57 done · 14 in progress · 38 planned. | F-088 · F-100 | Claude |
| 2026-09-03 | fix | Diagrams in docs are rendered, and they say PostgreSQL again. A ```mermaid fence in a markdown doc now renders through the same component the board's Architecture tab has always used — it existed and was simply unreachable from a document, so every diagram in docs/ was hand-drawn in box characters that do not reflow, cannot be themed, break when a label changes length and are silent to a screen reader. ARCHITECTURE.md is converted. In the process: mermaid puts class="label" on every label it emits, which collided with the brand .label device lifted from verjson.com (monospace, lowercase, _ prefix), so every diagram in the app rendered postgresql and next.js. The reset lives in globals.css beside the rule it undoes, because .label is unlayered and unlayered CSS beats anything in @layer utilities however specific — the obvious [&_.label]:normal-case on the container compiles fine and does nothing. | F-100 | Claude |
| 2026-09-03 | feat | User flows are drawn as journeys, and can be walked. A flow is a sequence and the page rendered it as a table of rows, which is a set — the order was in the data and nowhere on the screen. Steps now sit on a rail with a marker each, and a play button walks one step at a time (1.5s a step, scrolling nearest so a flow already on screen is not dragged around under the reader). Header gains N steps · M done and the page a 7 journeys · 2 complete line. Per-step state reads an optional status when the data carries one and otherwise follows the flow's own — a done flow means every step is done, which is what the page says out loud, and an in_progress flow honestly reports zero rather than inventing a 3 of 7 somebody would quote in a status meeting. | F-100 | Claude |
| 2026-09-03 | feat | Docs pages got a masthead, a progress bar and copyable code. The title floated with three captions stacked under it; it is now a masthead — the group it sits in as an accent eyebrow, the title, the summary, and the source file as a chip with a file icon, closed by a rule. Adds a two-pixel reading-progress line fixed to the top of the window (these documents run from four screens to forty, and the scrollbar at the far edge of a wide page is not something anyone reads as progress), a copy button on every fenced block pinned to the wrapper rather than the pre so it stays reachable while wide code scrolls under it, Back to top closing the contents column, table rows that respond to hover, blockquotes styled as the callouts they always were, and a bordered Back to app — the one way out of the docs, which as a bare link had the same weight as the thirty nav rows above it. | F-100 · F-090 | Claude |
| 2026-09-03 | fix | The architecture diagram was striped, and it was a parsing bug, not CSS. Markdown decided a code node was a block by looking for a language- class, so every fence opened as plain ``` was classified as INLINE code — and each of its lines got the inline pill's background and padding, which is what drew the horizontal bands behind the ASCII diagram. The pre component now marks its subtree through context, so a block is a block regardless of whether its author named a language. Same pass, the docs design: status pills move off primary onto the semantic tokens (green / amber / neutral), because primary is this app's navigation colour and a board a third full of blue "Done" pills reads as a field of links; each area gets a progress bar beside its fraction; the document sits on card white with the navigation on the warm page ground, so the two read as a frame around a document rather than one long page; the active nav row is a left rail instead of a filled block; and prose moves from muted-foreground at 14px to ink-2 at 15px — the muted token is for text that sits BESIDE the content, and applying it to the content made whole pages read as a caption. | F-100 · F-090 | Claude |
| 2026-09-03 | feat | The docs site reads like a docs site. Five things at once, because they were one problem: every page rendered its title twice (the manifest's above the body, and the file's own H1 inside it) — stripLeadingH1 drops the second; long pages had no in-page navigation while the right third of a wide screen sat empty — headings now carry stable GitHub-style anchors and an On this page column tracks the reader's position, taking the last heading scrolled past rather than the first one visible, so the highlight never points at a section they have not reached; thirty pages across six groups had no way to search them — the nav filters as you type over title, summary and slug, focused with /; the Feature checklist was a flat 109-row markdown table and is now a board grouped by area with per-area progress, a status filter and the description, dependencies, links and update history behind each row; and the sidebar shows a count where one is meaningful (40/109 on features). Fenced code is skipped when collecting headings — these docs are full of shell, and a # comment in a snippet is not a section. | F-100 · F-088 | Claude |
| 2026-09-03 | feat | Super Admin can finally do something. isPlatformAdmin existed on the user row and in PLATFORM_ADMIN_EMAILS, and was read in exactly one place: the /me payload. There was no guard, no route and no screen, so a platform admin was a person with a flag and no powers. Adds requirePlatformAdmin (denying with 404, not 403 — a 403 confirms to an ordinary tenant that a cross-tenant surface exists) and a /platform module: list every tenant with its owner and live user/project counts, open one, suspend or restore it, change a person's role or deactivate them inside it, and grant or revoke platform access itself. The grant route is the only one that steps up to MFA — it is the one action that widens the blast radius permanently. Three refusals are enforced server-side: you cannot change your own platform access, you cannot promote a deactivated account (that is a dormant backdoor), and you cannot revoke the last admin. Every write files its audit row against the target organization, so a tenant reading their own log can see the platform reached in. | F-003 · F-005 | Claude |
| 2026-09-03 | fix | A published post opens its record, not a date picker. Every row in Content links to the calendar's ?post= target, and the calendar answered that param with "Schedule this post" whatever state the post was in — so clicking a post to see what went live asked for a future date, and committing it failed at the API, where published is terminal and publishing/published are PROTECTED_STATUSES. Those posts now open the same read-only preview the calendar cards use: caption, status, and the permalink that proves it published. A partly published post keeps the schedule form — its unpublished variants can still be queued — and the submit skips the ones already out, which previously aborted the fan-out on its first rejection and left the remaining channels unscheduled. The rule lives in apps/web/src/lib/post-scheduling.ts so it can be tested. | B-015 · F-021 | Claude |
| 2026-09-03 | feat | The Reports tab is a report. It was a fixed 30-day window, four KPIs and a raw table. Adds a date-range selector (7 / 30 / this month / 90 days, resolved in UTC to match AdMetricSnapshot.day), six KPI cards including avg CPC and cost-per-conversion, an inline-SVG daily spend-and-clicks trend on independent axes, device and top-keyword breakdown widgets, and a CSV export that emits money in major units for the human opening it. Device and keyword data rides on a new segments field narrowed out of the snapshot's existing raw column — the widgets say "not collected yet" rather than drawing a breakdown of zero, because that would assert the campaign got no mobile traffic when it means nobody measured. Populating it needs per-device and per-keyword queries the Google adapter does not run yet. | F-050 | Claude |
| 2026-09-03 | feat | Google creative assets cover all five kinds, and a rename stops eating them. The creative modal only ever built a Meta-shaped image creative, so a Google advertiser had no way to add the text, sitelink, callout or video assets Google's asset library is mostly made of. Adds an asset-type selector on Google and a per-kind field set, with assets validated against the matching shape (AD_CREATIVE_ASSETS_SCHEMA) while kind stays free text so unmodelled platform kinds keep working. Fixes two bugs found on the way: the title read "New Google Ads Ads creative", and adCreativeUpdateSchema inherited assets' .default({}) through .partial(), so a rename-only PATCH wiped the creative's destination, caption and Page ID. | F-050 · F-055 | Claude |
| 2026-09-03 | feat | Disconnecting an account stopped being a full-width red row under the list you were reading. The Channels menu printed every handle twice — once at the top to read, then again at the foot as Disconnect @handle in destructive red, full bleed, directly beneath the list you scan to find an account. That second list was both the mis-click hazard and the only reason the first one existed in read-only form. They are now one row per account: the handle fills the left as a switch target, and the disconnect sits at the right edge as an icon in its own p-1.5 button — roughly a tenth of the area it had, with an explicit gap-1.5 between the two. The zones are siblings, not nested, so a click on the icon cannot reach the switch handler however the event travels; stopPropagation is belt-and-braces on top, and earns its keep for a second reason — it stops the click reaching the panel's closeOnSelect, so the menu stays open behind the confirm dialog and a second account can be dealt with without reopening it. Selecting does not stop propagation, so it still closes the popover. That asymmetry is the design. The icon is always visible, not revealed on hover. Hover-reveal is the conventional way to keep a list quiet and it is wrong for this control twice over: a touch screen has no hover, so the only route to disconnect would have been a target that never appears, and a keyboard user tabbing onto an opacity-0 button is focused on something they cannot see. The row is kept quiet instead by the icon's muted resting colour, which turns destructive only on hover — separation rather than concealment. The same row is now on the Content page's channel tabs, which could switch accounts but offered no disconnect at all, sending you out to Manage Connected Channels for it. Both surfaces call one useDisconnectAccountAction(): the confirm dialogs are literally the same code, which is what keeping them "intact" has to mean once two places offer an action whose second branch destroys analytics history permanently. Same Unplug, same Trash2-once-dormant, same toasts, same D-026 semantics — disconnecting keeps every published post and its metrics; only deleting an already-dormant account erases them. Account rows in the channel manager finally switch accounts, which that modal's own description has promised since it was written while the rows underneath sat inert; the tick moved to the right edge to mean "active", so the left icon now carries connection health only where there is something to warn about. The modal deliberately stays open on select — closing it would be the same "walks you out of the modal you opened" problem that linkToHub={false} exists to avoid, and someone switching accounts here is often about to disconnect one. One consequence handled: the tabs can now delete an account outright, and a handle filter pointing at an account that no longer exists matches no variant, which renders as an empty grid with every filter chip looking untouched — it reads as "you have no posts" rather than "your filter is stale". The pick is now honoured only while that account still exists, mirroring the fallback the page already had for platforms. Frontend only — no auth callbacks, endpoints, contracts or schema touched. | apps/web/src/lib/account-disconnect.ts, apps/web/src/components/ui/actions-menu.tsx, apps/web/src/components/{platform-tile,organic-hub-tiles}.tsx, apps/web/src/app/(app)/projects/[id]/content/{channel-bar.tsx,page.tsx} | Claude |
| 2026-09-02 | feat | Ads can be written and managed from the Performance hub. The Ads tab listed ads and showed a raw creative UUID; there was no way to create one, and nowhere for a Google responsive search ad's actual copy to live. Adds Ad.content (additive JSONB migration 20260902120000_ad_content, {} default, no backfill) behind a .strict() adContentSchema — final URL, display paths, and pools of headlines and descriptions with Google's caps and per-line limits. The new-ad modal renders add/remove lists with live counters driven by the same exported constants the schema enforces, and the table gains a status badge, a first-headline/display-URL preview, and Pause/Resume · Edit · Delete. The copy sits on the ad rather than the shared creative: a creative is reusable, so headlines stored there would let one ad's edit silently rewrite another's. | F-050 | Claude |
| 2026-09-02 | feat | Ad groups can be created from the Performance hub. The Ad groups tab listed them and told you creation was a follow-up slice, which left the only path to an ad group buried on the campaign detail page. Adds a + New ad group button beside the campaign picker and a short modal — name, line-separated keywords, optional default max CPC — wired to useCreateAdGroup, whose cache invalidation refreshes the table on its own. keywords and cpc_bid_minor are new validated fields on AdTargeting; match-type punctuation is kept exactly as typed. The full ad-set builder stays on the campaign page rather than being duplicated. | F-050 · F-051 | Claude |
| 2026-09-02 | feat | Google Ads campaigns are configurable as Google campaigns, and stop asking Meta's questions. The create form offered a name, an objective and a bid strategy — everything that decides whether a Google campaign can actually run was missing, while Meta's Special Ad Category declaration and the creative modal's required Facebook Page ID were shown to Google advertisers who have neither. Adds daily budget, target networks, locations, languages and flight dates as a validated google_settings object on the campaign contract, stored inside AdCampaign.extra (no migration) and mapped by the adapter into one atomic googleAds:mutate — budget, campaign and geo/language criteria together, with place names resolved through geoTargetConstants:suggest before anything is written, so an unknown name fails with nothing created rather than leaving an orphan campaign. Both platform-specific UI blocks are now gated on platform, and each adapter drops the other's vocabulary instead of posting it. | F-050 | Claude |
| 2026-09-02 | fix | The composer's preview panel warned about channels the post was not going to. GET /posts/:id/normalised with no ?platform= falls back to the whole SOCIAL_PLATFORMS list rather than to the post's own variants, so the response always carries nine payloads however many channels were ticked. Rendered raw, a post going only to Instagram showed nine cards — eight describing destinations that do not exist, one of which announced "4 hashtags removed" for a LinkedIn account the project has never connected. A warning about a channel you are not publishing to is worse than no warning: it reads as a real consequence, and it buries the one card that is real. The panel now filters the response to the platforms the post actually targets. Filtering on PLATFORMS rather than accounts is deliberate — the normaliser emits one payload per platform, so a post targeting two Instagram accounts still gets exactly one card. With nothing selected the filter empties the grid, which finally makes the panel's own placeholder reachable: it has always said "Select at least one channel", and until now the response it guarded could never be empty. | apps/web/src/app/(app)/projects/[id]/organic/[platform]/composer-modal.tsx | Claude |
| 2026-09-02 | fix | A text-only post showed its opening line twice on the Content grid. The media well has a second variant for posts with nothing attached, and it set the caption as a centred pull-quote on a tinted panel — quoted, in the display face, framed as an artefact. The card body below then printed the same caption again as the title, so one piece of writing arrived as two: a quotation and a heading. The well now flows the caption from the top-left like the text it is, carries the card's stretched link, and the body drops its duplicate title line. pt-11 clears the type badge and the ⋮, both absolutely positioned at top-2 — text sliding under them would be unreadable at exactly the moment there is nothing else on the card to read. The genuinely empty case (nothing written AND nothing attached) stays centred, because there is no text for the eye to start on. Media cards are untouched, and the aspect-square box stays either way so grid rows keep their baselines. | apps/web/src/app/(app)/projects/[id]/content/content-grid-card.tsx | Claude |
| 2026-09-02 | feat | Reviewing a post stopped being a page you leave the queue for. The approvals list was a set of links; deciding on anything meant navigating into /posts/:id/approval, deciding, and navigating back, with the list's counts recomputed behind you. A row now opens a Post Review dialog over the queue, and a recorded decision closes it — the row you just judged is behind the dialog and its status has already changed. The route survives and renders the same components: the calendar, the ad-proposals page and the post's own analytics tab all link into it, and analytics' back-link is literally "Back to the post" — a modal cannot be somewhere you navigate back TO. One body, two hosts, no second implementation. The review itself is two columns: what is being signed off on the left, the process on the right. Stacked, the reviewer scrolled away from the post to reach the buttons and had to hold the caption in their head while pressing them. The decision sits under the content it judges; the right column is the approval path over the conversation. The stage table became a trail — a column of dots, the active one ringed, decisions attributed by NAME rather than by the first eight characters of a uuid. Those names are resolved client-side from the org's user list (the same hook the delegation picker uses), because the approval API returns ids and nothing else; every one degrades to the short id when the list has not loaded. Per-platform content is reviewable at last. A post can carry its own caption, first comment and media per channel, and the preview now resolves each against the base post — NULL inherits, "" does not, which is the trap: a caption deliberately cleared for one channel must render empty, because inheriting the base one shows a reviewer text that will never publish there. Channel tabs appear only when more than one channel exists AND at least one is customised; four channels sharing a caption is one thing to review. Each tab marks whether it is customised, whether YOU have opened it (private, localStorage), and where the variant actually got to. Approve is held until every customised channel has been opened — one verdict covers every channel, and approving content you have not seen is the failure a tabbed layout makes easy. Decision notes are asked for only when needed: Approve submits immediately, Request changes and Reject arm a required note instead of submitting, so the destructive act is always the second click. +35 tests. Frontend only — no API, contracts, schema or compose changes. | apps/web/src/lib/{approval-review,approval-seen}.ts + test, apps/web/src/components/approval/, apps/web/src/app/(app)/projects/[id]/{approvals/[[...tab]],posts/[postId]/approval}/page.tsx, apps/web/src/components/ui/modal.tsx, docs/DECISIONLOG.md | Claude |
| 2026-09-02 | feat | Bulk import happens on the calendar now, instead of sending you away from it. The header's Bulk import button was a link to /calendar/import. Importing a sheet is something you do TO the calendar you are looking at, so navigating away to do it cost the month you had scrolled to, and coming back left you on a route that could not show what had changed. It now opens the same flow in a modal over the grid. The two-step shape is unchanged, deliberately: picking a file still runs a dry run and shows exactly what will be created, and the commit button still only lights up when every row is clean, because the import is all-or-nothing and a half-filled calendar cannot be told apart from a full one by looking at it. Putting it in a modal does not make that safe to skip, so the preview table renders in the modal body. Nothing about the upload, the parsing, the template download or the commit changed — the page's logic moved wholesale into useBulkImport, which both surfaces now call; the modal's footer needs the commit button outside the scrolling body, and a hook is what lets the footer and the body read one state instead of two copies that drift. On success the modal closes and toasts the count; the grid behind it repaints itself, because the mutation already invalidated the project's posts. /calendar/import still works and renders the same shared body — it was in the sidebar until recently and project-ia.test.ts pins it as resolvable for the bookmarks that outlived it. New: files can be dropped on the picker as well as chosen, both entering through one code path so the accepted types and the preview cannot diverge by route. One wart worth knowing: the file input's ref had to move OUT of the hook, because returning a ref beside the values the body reads makes React 19's react-hooks/refs rule treat every bulk.file read as a ref access during render — 33 lint errors from one misplaced field. Reset now remounts the input by key, which also gets "pick the same file twice" right for free. Frontend only — no API, parser, validation, contracts or schema changes. | apps/web/src/app/(app)/projects/[id]/calendar/{bulk-import.tsx,import/page.tsx,[[...tab]]/page.tsx}, docs/DECISIONLOG.md | Claude |
| 2026-09-02 | feat | The Content page's channel row stopped advertising what you don't have. It listed all eight organic platforms, so a project running one channel opened on seven placeholders each offering + Connect. That inverts a filter bar — it should say what you HAVE — and each of those tabs was also a lie about the grid beneath it, since clicking one always produced an empty page. The row now renders only channels the project has an account on, in hub order. "Has an account" deliberately does not mean "has a live token": a disconnected account keeps its published posts and their whole metric history (D-026), so its tab stays and its handle still reads — disconnected. Filtering on isPublishableAccount here would have hidden a channel's entire back catalogue the moment a token was revoked. Connecting moved off the tabs and into one dashed button at the end of the row, because it was the only control there that did not narrow the grid in place — it left the app for an OAuth round trip. It opens Manage Connected Channels, a modal rendering the Organic hub's own OrganicHubTiles unchanged: status badges, the account popover with disconnect and "Connect another account", and + Connect on the cards with nothing behind them. Rendering the real component rather than a copy is what stops the two surfaces drifting, and it reads its own accounts and invalidates the same query, so the tabs re-render the moment anything changes — no new wiring for "the tab bar updates automatically". The modal lives at ?channels=1, matching ?new=1 for the composer on this same page, which makes Back close it and — because the OAuth callback returns to whatever URL it is handed — brings someone back into the manager with the newly connected card already in the grid. OrganicHubTiles gains an optional redirectAfter so it can be told where that is; it defaults to the Organic hub, so nothing changes for its existing caller. Inside the modal the cards go nowhere — a second new flag, linkToHub={false}, drops the "Open hub" footer, the stretched link on the title, and the account rows' links into the platform hub, leaving connect and disconnect as the only things a card does. The hover lift and cursor-pointer go with them, since those exist to promise the card leads somewhere. This is groundwork as much as tidiness: the sidebar's Channels section and its sub-tabs are going, and a card whose main affordance opens one of those hubs is built on a floor being removed. The Organic hub's own grid is untouched — both flags default to today's behaviour. The unconnected card also lost its "Not connected" line and shortened its action to just Connect: the greyed mark, the missing account pill and a Connect action already said "not connected" three times, and naming the platform in the action made the one thing you can do the longest line on a card that pictures and names that platform directly above. The full name stays in the hit area's aria-label. The caveat that was not restating the obvious survives, shorn of its redundant prefix — Adapter not shipped yet and Manual setup still show on the two statuses where connecting works differently, because that changes what the click does. Two consequences handled: the selected channel is now only honoured while it still has a tab, so disconnecting the last account on it falls back to the first remaining channel instead of leaving the row unlit and the grid filtered to an invisible platform; and with nothing connected at all the row is one button plus a line saying so, rather than an unexplained empty strip. +5 tests. Frontend only — no API, contracts, schema or compose changes. | apps/web/src/lib/social-connect.ts + test, apps/web/src/app/(app)/projects/[id]/content/{channel-bar.tsx,page.tsx}, apps/web/src/components/organic-hub-tiles.tsx, docs/{DECISIONLOG,VERIFICATION}.md | Claude |
| 2026-09-02 | feat | The composer is two steps, and per-channel content is finally expressible. Step 1 asks who the post is for and what it is about — channels, campaign, brief; step 2 is the artefact — media, caption, per-channel detail. The split follows the order the work happens in, and it is why the channel picker leads: everything in step 2 depends on which channels are selected, from the character limit to whether a Visibility field applies at all. An edit opens on step 2, because the channels of an existing post are settled and paging past them to fix a typo would be a step that exists only for the create flow. A radio group ends step 1: shared content (the default — the same post going to three places is the common case) or per-platform, which gives each selected channel a tab holding its own media, caption, first comment, link and, on LinkedIn, visibility. Drafts seed from the shared fields the first time a tab is read, so switching modes after writing a caption gives you that caption on every tab to trim rather than three empty boxes. Per-channel content needed a second write: postCreateSchema carries ONE caption / media / link that seed every child variant, and the contract says so itself — default_platform_extra is "extras to seed on every child PlatformPost", each variant "individually overridden later via PATCH". So saving is create-then-PATCH via a new useUpdatePlatformPost, sequential and naming the channel in any failure, because a partial apply leaves a real post with some channels customised and some not. One uploader, not one per tab — it writes to whichever bucket is open (setActiveMedia); an instance per tab means several drop targets and several in-flight upload counters against several buckets, with a real question about what happens to the one you switch away from mid-transfer. The tabs lean hard on their selected state (tinted panel, full-strength border, bolder label, real brand mark) because each owns a DIFFERENT draft, so writing into the wrong one is silent until publish. Visibility moved out of step 1 into step 2 next to the content going to LinkedIn; Save as draft moved to the modal header beside the ✕, clearing the schedule on its way out — a draft with a time still set is a post the publisher will act on. Known and deliberate: the media cap is still the most permissive across selected platforms rather than the open tab’s, so a LinkedIn tab accepts up to Instagram’s 10 and the normaliser clips at publish, as before. | apps/web/src/app/(app)/projects/[id]/organic/[platform]/composer-modal.tsx, apps/web/src/lib/posts.ts, apps/web/src/components/ui/modal.tsx | Claude |
| 2026-09-02 | feat | Date-range picker: presets down the left, two months side by side. Almost every range drawn by hand crosses a month boundary, and in a single-month calendar that is a click, a month-flip, and a second click with the first end of the range off screen. The preset list gains This month, Last month and Next 30 days. The important pair is Last 30 days against Last month: the old code had one preset named last-month that actually meant the trailing 30 days. They look interchangeable and disagree — on 8 June one reaches back to 10 May and ends today, the other is May exactly. A test asserts they never return the same window, and another covers 31 March, where February must end on the 28th rather than overflowing. This month covers the whole calendar month, not month-to-date, and Next 30 days exists at all, because this filter runs over a content plan as often as an archive — stopping at today would hide the scheduled half of the month on screen, and no backward preset can ask what is going out. Two layout details that are not cosmetic: the panel needs w-max, because an absolutely-positioned box shrinks to fit but is capped by its containing block — here a small trigger button — so without it the grids collapse below the day buttons and the numbers overlap; and the columns are pinned rather than auto-sized, or a month of single-digit rows renders narrower than the one beside it and the pair resizes as you page. +4 tests. | apps/web/src/components/ui/date-range-picker.tsx, apps/web/src/lib/date-ranges.ts + test | Claude |
| 2026-09-02 | feat | Content page: channel tabs carry their account, and cards say what kind of post they are. The platform pills and the account dropdown were separate controls, so the handle belonged to "whichever tab is selected" and you had to select a tab to discover what was connected to it. Each tab is now two lines — platform on top, handle underneath — so the row answers "these are my channels and these are the accounts behind them" without a click; picking a handle also selects its platform, because reaching into another tab’s dropdown is a clear statement of which channel you want. Post cards regain a content type: a badge top-left on the media for grid cards (replacing the old "Video" chip, which said the same thing for one of four types and nothing for the other three) and a pill at the end of the meta line for list rows, after the channel count rather than between the status and the date — status, when and where is one phrase, and two adjacent pills read as a pair of statuses. The two behave differently under an active Type filter on purpose: the grid badge is absolutely positioned so dropping it reflows nothing, and the filter is page state rather than a URL param, so nobody arrives with it already set; the list pill is in normal flow and its row has no media well saying "video" for it. Text only became Article — on LinkedIn and X a post with no attachment is a format people choose, and the old label described it as a lack. | apps/web/src/app/(app)/projects/[id]/content/{channel-bar,content-grid-card,page}.tsx, apps/web/src/lib/content-view.ts | Claude |
| 2026-09-01 | feat | Content became a place you can browse, and the calendar stopped conflating three questions. (1) The calendar's switcher was one flat strip — month / week / content / ads — which forced a false choice: picking "Content" meant leaving the month behind, and "this month, as a list" could not be asked for at all. They are three axes and are now named once each: the dataset (scheduled posts vs ad campaigns) is navigation, so it lives in the sidebar and the URL; the period (month/week) is a zoom level; the layout (grid/list) is how it is drawn. Both of the latter are page state. monthly and weekly retire to content, which is what those URLs showed anyway. Content Calendar and Ad Calendar return to the sidebar and this is not a reversal of D-034 — that decision removed children restating a tab strip, and these two no longer do. Bulk import leaves the sidebar for the page header: it is an action on the content calendar, not a third thing to look at, and sitting between two datasets it read as one. Its route is unchanged. A new isDefault flag on an IA child fixes the fallout — /calendar carries the section's URL, not the child's, so the group lit up with nothing selected under it. (2) The Content page listed posts and little else. It now opens on a channel bar (one platform at a time, defaulting to the first with a connected account, so a LinkedIn-only project does not land on an empty Instagram tab), over a toolbar of search, post type, a shared date-range picker and a grid/list toggle whose preference is stored per project — a video-heavy brand wants the grid, the same person's copy-only project wants the list. The known gap is recorded rather than papered over: with no "all" tab, a post with no channel attached — which most drafts are — is not reachable here, and is reached from the calendar or the composer. (3) Both hubs now offer the same actions under the same names. Instagram said "Edit" where Content said "Continue editing", "Insights" against "View post", and the Content card had no publish, reschedule or review action at all. lib/post-actions.ts owns one ORDERED list — first entry is the primary button, the rest fall into the ⋮ — because which action leads is a property of the status, not the screen. Capabilities are injected, so a Content row for a post with three channels offers no publish rather than one that fails. (4) The composer opened on the Caption, the field you can write LAST, since it depends on the channel limit, the script and the media. Reordered to who it is for, then what it says, then when it goes out — which also mirrors the two-stage approval. Generate caption was rewriting whatever sat in the caption box, so on an empty composer it had nothing to work from; it now takes explicit context built from the script, channels and media, trimmed to the contract's 4000-char cap. +44 tests (three new suites: date-ranges 16, post-actions 14, content-view 12). Frontend only — no API, contracts, schema or compose changes. | apps/web/src/lib/{date-ranges,content-view,post-actions,section-tabs,project-ia,content-buckets}.ts + tests, apps/web/src/components/{platform-tile,ui/date-range-picker,ui/actions-menu}.tsx, apps/web/src/app/(app)/projects/[id]/{content/,calendar/,organic/}, docs/{IA,DECISIONLOG,VERIFICATION}.md | Claude |
| 2026-08-31 | fix | Provider failures return 424, not 502 — Cloudflare was eating the diagnosis. Cloudflare replaces an origin-generated 502/504 with its own branded error body, so every provider-passthrough 502 (analytics picker, ads/channels/analytics OAuth, publish, inbox reply, billing, identity OAuth) reached the browser as CF's "origin returned an invalid or incomplete response" copy instead of the route's detail — in prod, the picker message #192 built never survived the edge. All nine sites now throw PROVIDER_ERROR_STATUS (424 Failed Dependency), which the edge passes through untouched and which keeps a provider outage distinguishable from our own gateway being down. | apps/api/src/core/errors.ts, apps/api/src/modules/{analytics,ads,channels,inbox,posts,oauth,billing}/* | Claude |
| 2026-08-31 | fix | GA/GSC property-picker failures are now diagnosable. Every outbound Google call gets a 15s deadline (a hang was surfacing as a bare CDN 502); a Google 403 maps to invalid_request instead of token_revoked, so a disabled API / ungranted scope no longer disconnects the connection on a loop (the LinkedIn PR #191 split); the picker modal renders the API's real error detail instead of one fixed "reconnect" sentence. | apps/api/src/modules/analytics/adapters/*, analytics/routes.ts, performance/analytics-providers/page.tsx | Claude |
| 2026-08-31 | feat | Meta pixel picker + Conversions API forwarding (F-114, F-115). Two halves of the same problem: making conversion measurement something you configure correctly, and something that keeps working once you have. |
The picker. The ad-set builder asked for a Meta pixel id as fifteen typed digits. There is no structure to that number and no check character, so the wrong one is not an error anywhere — Meta accepts it, the ad runs, and the campaign optimises toward events it will never see. The only symptom is a conversion count that sits at zero, and by the time that reads as a configuration problem rather than a bad audience, a week of budget is gone. GET /ad-accounts/:id/pixels now reads the account's pixels off Meta's adspixels edge and the field is a dropdown of names, each showing when it last fired — which is what distinguishes the live pixel from the three left over from previous sites. The text box stays as the fallback for the three cases that all want the same answer (the fetch failed, the account has no pixels, the stored id isn't in the list), because a dropdown is a convenience and must never become the reason a campaign cannot be configured.
The forwarding. POST /track/conversion now also sends the conversion server-to-server to Meta's Conversions API. The browser pixel is the least reliable part of the measurement stack — Safari's ITP caps its cookies, ad blockers remove the script, iOS ATT removes the identifier underneath it — and what Meta cannot see, Meta cannot optimise toward, so a campaign buying "purchases" spends its budget learning from whatever fraction survived the trip. This path has no browser to block and no cookie to expire. The destination pixel comes from AdGroup.targeting.pixel_id — the field the ad-set builder writes (D-031) — so the pixel a campaign optimises against is by construction the one its conversions reach, deduplicated across ad sets because several routinely share one and repeating the send is triple-counting rather than redundancy.
Three details that are the whole difference between working and looking like it works. Value is converted to major units (4999 → 49.99), because sending minor units overstates revenue a hundredfold and the ROAS optimiser believes it. event_time is epoch seconds; milliseconds are accepted by Graph and land the event fifty thousand years past the attribution window. event_id carries the conversion's own id, so the browser pixel and this path cannot count the same purchase twice. The brief specified act_{account}/events, which is not a real endpoint — CAPI is scoped to a pixel, and posting to an act_ node returns "path /events does not exist", so building it as written would have failed on every call and, because this path swallows its errors so it cannot slow a checkout page, failed in silence (D-032). The only identifier sent is a hashed external_id; no raw IP leaves the process, because the touchpoint table stores ipHash and never an address and shipping the raw one to a third party would make that posture a fiction (D-033). Forwarding is fire-and-forget: a Graph outage, a rejected event and an unreachable host all leave the tracker returning 201 with the conversion recorded. | F-114 · F-115 · apps/api/src/modules/ads/pixels.ts · apps/api/src/modules/attribution/capi-forwarder.ts | Claude |
| 2026-08-31 | feat | A rich email composer (F-113). The broadcast composer was four fields and a <textarea>; it is now the screen the feature deserved. A formatting toolbar (font, size, bold / italic / underline / strikethrough, text colour, highlight, alignment, bulleted and numbered lists, indent / outdent, blockquote, undo, redo, clear formatting), hyperlinks with their own display text, inline images by upload or drag-and-drop, file attachments, a merge-tag picker that works in the subject line as well as the body, a Desktop / Mobile preview, a test send, drafts that autosave, saved templates, a project signature, and a full-screen mode. The layout is deliberately Gmail's — this is the screen where getting it wrong mails the wrong thing to a few thousand people, and a familiar shape is a smaller source of mistakes than a clever one.
Four things are worth knowing about how it is built. The sanitiser is shared and it runs on write (D-030): the same allow-list function in @verjson/contracts cleans the body in the browser preview, in the API on save, and again on a signature at render — so what the preview shows, what the database holds and what the recipient receives cannot diverge, and no read path can forget to clean anything. It is an allow-list at four levels (tags, attributes, URL schemes, CSS properties), so a payload nobody has thought of fails closed. The editor is contenteditable + execCommand (D-029) rather than an editor library, because every button here is one command every browser implements, and a document-model editor would have to be taught to emit exactly the subset of HTML the sanitiser permits or watch the server strip its output. The signature is inserted into the body, not appended at send (D-028), because a sign-off the sender never saw in the editor or the preview is a paragraph added to their email by something they never saw. A test send shares none of the dispatch path: no recipient rows, no status change, no counters, no audience resolution — "I only meant to test it" is not a recoverable state.
Every broadcast now goes out multipart, with the text part derived from the HTML rather than typed twice, because a message with no text/plain part renders as nothing in a client with HTML disabled. Merge tags are escaped into the HTML part and not into the subject or text part — the contact table is filled by CSV and Google Sheets, so a name is input from outside, and getting that split wrong is either an XSS in every other recipient's webmail or a literal & in the inbox. Attachment bytes are read from our own storage by key, once per dispatch rather than once per recipient, and a key outside the caller's organization prefix is a 404 before anything is read. Drafts autosave into a real row from the first save, so a closed tab recovers through the broadcast list, which has always shown drafts — and the indicator says "Draft saved 14:22" only after the server said so. Nothing drafted before this change behaves differently: body_text stays NOT NULL, a text-only broadcast is still sent text-only with no HTML part invented for it, and the 76 existing broadcast tests pass unchanged. | F-113 · packages/contracts/src/email-html.ts · apps/api/src/modules/email-broadcasts/{render,attachments}.ts · apps/web/src/components/email/ | Claude |
| 2026-08-27 | feat | Import contacts from a Google Sheets link (F-111.2). The import modal grows a second source tab: paste a sheet URL instead of uploading a file. Everything around the source is unchanged — the fallback-segment picker, the auto-create-categories switch and the result summary are shared — and so is everything underneath it: the sheet is fetched server-side through Google's gviz/tq CSV export and handed to the same parseContactsCsv a file goes through, so header aliases, address normalisation, custom-category matching, in-file de-duplication and the 20k row cap cannot drift between the two paths. The link is validated in the browser by the same parser the API runs, from the contracts package, so a typo costs no round trip and the field can never accept something the server will reject. SSRF is closed structurally, not by allow-list. The obvious build — check the pasted URL's host, then fetch that URL — is the one that keeps failing in the wild, defeated by redirects, by userinfo (https://docs.google.com@evil.test/), and by DNS rebinding. So the server never fetches the pasted URL at all: it extracts an id matching an anchored [A-Za-z0-9_-]{20,200} and builds a fresh URL against a hardcoded https://docs.google.com, with redirect: "manual" so an off-Google Location is a refusal rather than a second request. The user controls no scheme, host, port or path segment of the request actually made; the host list is a usability gate for error messages, nothing more. Scope and role are checked before the fetch, so the endpoint cannot be used as a probe for whether an arbitrary spreadsheet exists, and it is rate limited at 10/min as the only endpoint that makes an outbound request on user input. Failures are written for the person who pasted the link: a private sheet names "Anyone with the link" rather than reporting a 302, and Google's HTML sign-in page is caught before it reaches the CSV parser and gets misreported as "missing an email column". Oversized bodies are aborted mid-read rather than buffered and then measured. Contacts record source: "google-sheet:<id>", so "where did this address come from" stays answerable. | F-111.2 · packages/contracts/src/email-broadcasts.ts · apps/api/src/modules/email-broadcasts/google-sheets.ts · apps/web/src/app/(app)/projects/[id]/email-broadcasts | Claude |
| 2026-08-27 | feat | Custom audience categories for email broadcasts (F-111.1). The Audience header is no longer three fixed cards: a project can create its own segments — name, description, colour — from a Create category card sitting at the end of the same grid, and they appear beside the built-in three with live counts, filter on click, and can be renamed or deleted in place. Contact rows re-file into any of them from the inline segment dropdown, the composer targets one and shows its sendable count before Send, and CSV import matches them by name, case- and whitespace-insensitively (Enterprise Renewals = enterprise renewals = ENTERPRISE-RENEWALS), with an opt-in switch to create the ones a file names but the project lacks. The downloadable template now lists the project's own categories as example rows — built in the browser, because the template route is deliberately unauthenticated and an <a download> cannot carry a localStorage bearer. Three decisions carry the feature. One audience column, not two: email_contacts.category became nullable next to a new custom_category_id, with a CHECK constraint enforcing exactly one — the tempting alternative (keep the enum NOT NULL as a "fallback") leaves a contact in the custom segment "VIP" still matching WHERE category = 'active_clients', and one audience query forgetting AND custom_category_id IS NULL mails the wrong list. Deleting a category is never a silent audience loss: one holding contacts answers 409 with the head count until a destination is named, and the contacts move rather than vanish (the FK is RESTRICT as a backstop). History outlives its category: a broadcast snapshots the segment name into audience_label at draft time and its FK is SET NULL, so "who was this sent to" survives a rename or a delete — and a draft whose audience was deleted refuses to send by name instead of guessing one. Uniqueness is per project, not per org, so two clients of one agency can both have a "VIP"; a name the CSV importer already reads as a built-in segment is refused at creation, because built-ins win a collision by design and a shadowed category would be silently unreachable from a spreadsheet. | F-111.1 · packages/contracts/src/email-broadcasts.ts · apps/api/src/modules/email-broadcasts/* · apps/web/src/app/(app)/projects/[id]/email-broadcasts · migration 20260827100000_email_custom_audience_categories | Claude |
| 2026-08-27 | fix | A Slack integration could die and go on looking healthy forever. Deleting an Incoming Webhook inside Slack changes nothing on our side: the row stays verified: true — that flag only ever recorded that one send worked once, on the day it was set up — and every subsequent notification POSTed at a URL Slack answers 404 no_service. The transport logged it and swallowed it, per MUST-NOT-throw. So the settings page kept showing a green Verified badge, the notifications kept not arriving, and the only record was a server log nobody reads. This is the exact failure UserOutboundWebhook was already built to catch, and Slack simply never got the same treatment. Four columns bring it to parity — disabled_at / consecutive_failures / last_failure_at / last_failure_message — and the fan-out now filters on disabledAt: null, so a dead hook stops being re-dialled on every notification for the rest of time. Two ways to give up, because Slack distinguishes them for us. A revoked hook answers 404, an uninstalled app answers 410, a workspace-disabled hook answers 403: none of those un-break by waiting, so we disable on the FIRST one and say so. Everything else — 5xx, rate limits, timeouts — is transient by nature and goes through the 10-consecutive-failure counter, so one bad afternoon at Slack does not cost a user their integration. An undecryptable webhook URL disables immediately too: that means ENCRYPTION_KEY rotated out from under the row and no send will ever succeed again. At the moment of disabling the owner gets an in-app slack_integration_disabled notification — which is deliberately never sent through Slack, and needs no explicit exclusion to be safe: by the time it is emitted the row is already disabledAt, so the fan-out skips it for free. Recovery has two doors because the causes are two. A revoked hook needs a NEW URL, so re-registering clears the whole failure history rather than inheriting it. A receiver that was merely down needs the switch flipped, so POST /user/slack/enable clears the disable with no send at all — and a successful POST /user/slack/test does the same implicitly, because making someone hunt for a separate Enable button after the app has just proved the hook works is ceremony. A failed manual test records its reason but pointedly does NOT count toward auto-disable: a user pressing a button is diagnostics, not traffic, and ten frustrated clicks must not disable an integration they are actively repairing. The test endpoint now returns Slack's own reason (no_service, channel_not_found) instead of the app guessing on the user's behalf. Panel leads with Turned off over the green badge, because "your notifications stopped a week ago" is the thing worth knowing. +16 tests (12 API, 4 web). | apps/api/prisma/schema.prisma + migrations/2026082712{0000_slack_integration_health,0100_slack_integration_disabled_kind}/, apps/api/src/modules/notifications/{slack,slack-routes,emit}.ts, packages/contracts/src/{user-slack,notifications}.ts, apps/web/src/lib/{user-slack,connection-health,integrations-hub,notifications}.ts, apps/web/src/app/(app)/settings/integrations/slack-panel.tsx, apps/api/src/test/slack.test.ts | Claude |
| 2026-08-26 | feat | Per-project email broadcasts (F-111). A new Email Broadcasts section in the project sidebar, directly under Campaigns, with two tabs. Audience is the mailing list the project owns: drag-and-drop CSV import, filed into three fixed segments — Active Clients & Buyers (active_clients), Inbound Web Leads & Visitors (inbound_leads), Compute Supply Partners & Hosts (supply_partners). The segment is a Postgres enum rather than free-text tags on purpose: "who did we mail" has to still be answerable in six months, and free text drifts into Leads / leads / Lead inside a week. Broadcasts composes one plain-text email against one segment and dispatches it over the project's configured SMTP transport (the same config.email block that already carries invitations and notification mail), then shows a per-address delivery table. The importer is forgiving on the way in and strict on the way out — BOM, E-mail/Segment header aliases, mailto: prefixes, "Name" <a@b.com>, and human category labels all parse; anything that still isn't an address is rejected with its row number rather than dropped, because an import that silently loses 12% of a spreadsheet isn't found out until the campaign under-delivers. Three things are load-bearing and tested as such: an unsubscribe is a suppression flag, never a deleted row, so a re-import cannot resurrect somebody who opted out; a second click on Send loses a conditional draft → sending claim (updateMany on status) and gets a 409 instead of mailing the list twice; and every address carries its own outcome, so a partial send reports which addresses failed rather than a green tick. Sending is owner/admin only — drafting and curating the list is member work, pushing mail to a few thousand external addresses under the client's name is not. A deployment with EMAIL_ENABLED=0 refuses the send with a 422 up front instead of writing N identical failed rows. 100% additive: three new tables, one new router mounted at /, one new sidebar entry — no existing model, route, contract or page changed shape. Still to come: HTML bodies (needs sanitisation + an unsubscribe-footer injector), scheduled sends, and open/bounce tracking — the recipient status vocabulary is deliberately dispatch only, so the UI never claims knowledge it doesn't have. | apps/api/prisma/schema.prisma · apps/api/src/modules/email-broadcasts/* · apps/api/src/app.ts · packages/contracts/src/email-broadcasts.ts · apps/web/src/lib/{email-broadcasts,project-ia,section-tabs}.ts · apps/web/src/app/(app)/projects/[id]/email-broadcasts/[[...tab]]/page.tsx | Claude |
| 2026-08-26 | feat | The Baileys WhatsApp bridge exists (F-048, step 1). services/whatsapp is a standalone ESM Node 22 service on :4100 — deliberately outside the npm workspace, so it can hold a stale Baileys pin without dragging the API's dependency tree with it. It manages one long-lived WhatsApp Web socket and exposes it over HTTP: GET /health (open — it is the container probe and reveals only liveness) and, behind x-bridge-token, GET /status, GET /qr, POST /send and POST /disconnect. The QR comes back as a PNG data URL and expires after 60 s rather than being served stale, because a stale code costs a wasted scan and looks like a broken pairing. /send refuses with 409 when the socket is not open instead of dropping the message silently. /disconnect logs the device out, wipes the auth volume and comes back up un-paired, which is the whole re-pair path. Inbound messages.upsert, delivery receipts and connection changes are POSTed to WEBHOOK_URL with the same token, fire-and-forget: a webhook failure logs and is dropped, never taking the socket down or failing a caller's send. Reconnects use exponential backoff to 30 s; a loggedOut close clears the auth state instead of looping on dead credentials. Baileys is pinned to 6.7.24 on purpose — npm's latest tag is a 7.0.0-rc, and legacy is the last stable line. Still to come: the API half (POST /api/v1/whatsapp/events, the ChannelProvider adapter, opt-in, templates, the verjson.whatsapp consumer). ⚠️ Unofficial client — D-015 stands. | services/whatsapp/src/{index,baileys,routes,config,webhook,logger}.ts · services/whatsapp/Dockerfile · docs/INFRASTRUCTURE.md | Claude |
| 2026-08-31 | feat | The project shell stopped saying everything twice. Three separate duplications, one shape. (1) The sidebar restated tab bars that sit directly below it. Content listed New post / All posts / Drafts / Scheduled / Published, Calendar listed Monthly / Weekly / Content Calendar / Ad Calendar, Approvals listed Pending / Approved / Rejected / Feedback — and every one of those resolved to a route the page already reaches through its own controls, several of them to the same URL with a query string. Only the two children pointing somewhere a tab strip cannot reach survive: Media Library (still /measure/media) and Bulk import (a static route with its own upload + preview state). Approvals became a leaf like Inbox and Publish log. The tab bars are not generated from these children — the ids live in lib/section-tabs.ts, which was not touched — so this is a sidebar change and nothing else; had they been generated, deleting the children would have emptied the calendar's view switcher. Two tests pin that independence in both directions, because the monthly/weekly slug mappings are now unreferenced by the IA and read as dead code to anyone tidying up. (2) The breadcrumb band spent a full horizontal strip on Projects › <project> › <section> above a sidebar that already marks the active section. Deleted; project identity moved into a new switcher at the top of the sidebar — name, status badge, and a dropdown listing every project, which the breadcrumb could not do: getting from one project to another meant going out to the list and back in. The breadcrumb's one irreplaceable affordance, the link back to /projects, survives as a pinned All projects row at the foot of the panel. It is a disclosure, not a role="menu" — the items are links, so tab order and Enter already work, and advertising arrow-key roving that isn't implemented reads worse than no role. (3) The calendar toolbar gave a passive view selector the same solid bg-primary fill as the page's only call to action, and put + New post at the end of a control row directly above the grid, detached from the page it acts on. The CTA moved up onto the heading row; the switcher became a raised chip on a bg-muted trough (the pattern already on the marketing hero board), with hover: pinned on both branches because the shared ghost variant's hover:bg-muted would otherwise make an unselected chip identical to a selected one. The date range label moved between the arrows where a date shifter is read — keeping its "go to today" reset, which the visible text no longer states, so it carries an explicit aria-label and title rather than silently losing a working affordance. Arrow buttons were size="icon" (40px) in a row of 32px controls, stretching that group 8px taller than the switcher beside it. +5 tests, −1 rewritten. Frontend only — no API, contracts, schema or compose changes. | apps/web/src/lib/project-ia.ts + test, apps/web/src/app/(app)/projects/[id]/{layout.tsx,project-switcher.tsx}, apps/web/src/app/(app)/projects/[id]/calendar/[[...tab]]/page.tsx, docs/{IA,DECISIONLOG,VERIFICATION}.md | Claude |
| 2026-08-26 | feat | Teams, individual roles and work assignment — and the one thing they deliberately do NOT do. The ask was teams with roles that can be handed work. The spec that came back merged two different ideas into one column: it replaced project_members.role (a permission — lead/contributor/viewer) with a job title (Designer, Writer). Those answer different questions, and "Designer" does not say whether someone may edit the project — so building it that way would have quietly dismantled access control. Four things are called a "role" here and only two grant permission: workspace role and project role decide what you may do; team and individual role describe who you work with and what you make. This ships the two descriptive ones and leaves the two permissions untouched. project_members has not been altered, and a team grants access to nothing — pinned by a test where a team member with no project_members row still gets 404 on the project. Two routes to the same access is how "why can this person see this?" stops having a short answer, and we invite agencies into this system. Sub-teams were asked for (video / infographic / carousel) and deliberately not built: granular individual roles inside one team answer "whoever does video in Design" as (Design, Video Editor) — the same question, two levels of hierarchy instead of three, and no third level for every screen and permission check to carry. Assignment can name a team rather than a person, which a single assignee column cannot express: the work shows in My Work for everyone on that team as Waiting on your team until somebody picks it up, then disappears from the others' lists. Several assignments per post, because a post needs a designer AND a writer. Status and due date live on the assignment, and the assignee may close their own work without project write access — otherwise every status change would need someone with edit rights, which defeats handing work to a contributor. New /my-work in the main nav; assignment panel on the post in Calendar; Settings → Teams. Chat was requested and refused (F-113): Slack is already integrated and a usable chat needs threads, unread state, search and mobile — a product, not a feature. +27 tests (17 API, 10 web). | apps/api/prisma/schema.prisma + migrations/20260826120000_teams_and_assignments/, apps/api/src/modules/teams/, packages/contracts/src/teams.ts, apps/web/src/lib/teams.ts, apps/web/src/app/(app)/{my-work,settings/teams}/page.tsx, apps/web/src/components/assign-panel.tsx, apps/api/src/test/teams.test.ts | Claude |
| 2026-08-25 | feat | Fine-grained ad-set location targeting in the UI. New LocationPicker component with a Target-location-type selector — Country / State-region / City + radius / Zip-postal — backing the regions / cities / zips fields the mapper already understood but nothing could set. Items render as removable chips. One kind is active at a time, and that is a correctness rule, not a simplification: Meta unions everything inside geo_locations, so a city radius sitting beside a country is not a narrower target — it is the whole country at the whole country's cost, and the platform's response says nothing. selectionToTargeting emits only the active kind, which matches the precedence the server already applies in toMetaGeoLocations, so what is stored equals what will run. Region/city/zip take Meta's platform KEY (a typed place name is a 400), with an optional label carried for display. Radius is capped per unit (50 mi / 80 km) in the input as well as the schema. The publish-readiness check and the ad-set card now count and describe every location kind rather than countries alone, mirroring assertRunnableTargeting. Country-only ad sets keep exactly the shape and default they had. | apps/web/src/components/performance/location-picker.tsx · apps/web/src/lib/ad-builder.ts · apps/web/src/app/(app)/projects/[id]/performance/campaigns/[campaignId]/page.tsx | Claude |
| 2026-08-25 | fix | Every PATCH to an ad campaign was silently wiping its extra bag. adCampaignUpdateSchema was derived from adCampaignCreateSchema.partial(), and .partial() does NOT stop zod filling a .default() when the key is absent — the exact trap adTargetingPatchSchema was written to avoid, ten lines further down the same file. So a rename, an objective change, or a pause arrived at the domain carrying extra: {}, passed the input.extra !== undefined guard, and replaced whatever platform config the campaign held. Invisible until something read the column back. Fixed by deriving both shapes from an undefaulted adCampaignBase, the same way the targeting schemas already do. Found while wiring special ad categories, which store into extra — the bug would have turned a declared Housing campaign into an undeclared one on the next edit. | packages/contracts/src/ads.ts · regression test in apps/api/src/test/ads.test.ts | Claude |
| 2026-08-25 | feat | Meta special ad categories. A campaign can now declare itself as Employment, Housing, Credit, or Social issues/elections/politics — the declaration Meta requires for regulated ads and rejects (or later takes down) a campaign for omitting. AdSpecialCategory is our vocabulary; adapters/meta-ads.ts::toMetaSpecialAdCategories owns the mapping to EMPLOYMENT/HOUSING/CREDIT/ISSUES_ELECTIONS_POLITICS, and none maps to the empty list Meta reads as "ordinary commercial ad". Stored inside AdCampaign.extra (no migration) but validated as a first-class contract field — "none" is exclusive, duplicates are refused. Defaults to ["none"], so every existing caller is unchanged. The declaration is pulled OUT of the adapter's generic extra passthrough before the body is built, so the raw internal value can never overwrite the mapped one; it is dropped entirely from updateCampaign, because Meta fixes the field at creation. The domain refuses to change it once a campaign has an external_campaign_id and points the user at Duplicate instead. Picker added to both campaign-creation paths (the hub modal and the 5-step wizard), locked with an explanation on a published campaign. | packages/contracts/src/ads.ts · apps/api/src/modules/ads/domain.ts · apps/api/src/modules/ads/adapters/meta-ads.ts · apps/web/src/components/performance/ad-campaign-form.tsx · apps/web/src/app/(app)/projects/[id]/performance/campaigns/new/page.tsx | Claude |
| 2026-08-25 | feat | Duplicate an ad campaign. POST /ad-campaigns/:id/duplicate deep-copies a campaign — its ad groups, their ads, and the budgets still in force — in one transaction, and a Copy button on each row of the Google/Meta Ads campaign table calls it. The copy is deliberately inert: draft status on the campaign and every ad, blank external_campaign_id / external_ad_group_id / external_ad_id, no adapter call anywhere on the path. Duplicating a live campaign therefore cannot produce a second live campaign — the copy exists only in our database until someone runs the existing publish flow on it. Expired budgets are left behind (they describe spend the copy will never make); ad_creative_id is shared rather than duplicated, because creatives are project-owned and built for reuse. The audit row records duplicated_from, which is the question anyone reading that trail is actually asking. | apps/api/src/modules/ads/domain.ts::duplicateAdCampaign · apps/api/src/modules/ads/db.ts::findAdCampaignTree/createAdCampaignDeepCopy · apps/api/src/modules/ads/routes.ts · apps/web/src/lib/ads.ts::useDuplicateAdCampaign · apps/web/src/components/performance/ad-platform-hub.tsx | Claude |
| 2026-08-25 | fix | The tracker key had no reader — the whole ingest side was unreachable. GET /projects/:id/tracker-key has minted a key on demand since E6, and nothing in the dashboard ever called it. The only tracker_key anywhere in the product was the literal trk_YOUR_KEY placeholder on the public /tracker docs page. Visits, visitor paths, attribution and conversions all depend on the snippet being live on the customer's site, and the snippet cannot be written without that value: the pipeline worked and nobody could switch it on. New Website tracker panel on the project Integrations page — the real key, the snippet with project_id, tracker_key and api_base already filled in, and a copy button. Rendered complete rather than as a template because every substitution error fails the same silent way: the tracker posts, the API rejects the key, and the dashboard goes on saying "no visits recorded", which reads identically to "nobody came". Three things are pinned by test for the same reason — api_base is the API origin, not the /api/v1 path axios uses (passing it through unchanged posts to /api/v1/api/v1/track/visit, a 404 on every pageview); the queue stub is defined before init runs, because the script tag is async and the first visits after an install are the ones that matter; and verjson('page') is present, without which an installed tracker records nothing at all. Owner/admin only — the key authorises writes into the project's traffic data — and the panel names the reason rather than looking broken. +7 tests. | apps/web/src/lib/tracker-key.ts + test, apps/web/src/app/(app)/projects/[id]/integrations/{tracker-panel.tsx,page.tsx} | Claude |
| 2026-08-25 | fix | Approvals: a missing DEFAULT workflow was reported as a missing workflow, and could not be fixed from the UI. Two halves of one dead end, hit on production. submitForReview falls back to the project's default whenever a post is sent without naming one — which is what every button in the app does — so a project whose only workflow was not flagged default refused every submission. The 409 said "This project has no approval workflow yet", which was false: the workflow existed. That sent the reader to a settings page where everything looked correct and nothing explained the refusal, burning the one place they were going to look. The message now asks which case it is before choosing its words. The second half: the default flag was settable only at creation, and the table offered nothing but delete — so a single missed checkbox meant rebuilding the workflow and its stages by hand. PATCH /approval-workflows/:id already accepted is_default; it had no caller. Added useUpdateApprovalWorkflow and a Make default action in the Default column. Making one default un-flags the previous one server-side, so it is one click rather than a two-step swap. | apps/api/src/modules/approvals/domain.ts, apps/web/src/lib/approvals.ts, apps/web/src/app/(app)/settings/approvals/page.tsx | Claude |
| 2026-08-25 | fix | A draft could not be deleted from the page that lists drafts. Delete lived only on the per-channel hubs — the one place you are not when clearing out drafts, since the sidebar's Drafts link lands on /content. Worse, a draft with no channel attached (which most are) had no hub to be found on at all, so it could not be deleted from anywhere in the app. Reuses DeletePostModal, so the published-vs-local distinction survives being reached from a second screen. The button is always visible rather than hover-revealed: hiding it is how it went missing in the first place, and on a touch screen there is no hover, so it would simply never appear. Only rendered when the reader can actually delete the post — caught in the browser, where a client user saw a Delete button on every row and got a 403 on click. canDeletePost mirrors posts/domain.ts::remove with tests that fail if the two drift. +7 tests. | apps/web/src/app/(app)/projects/[id]/content/page.tsx, apps/web/src/lib/post-permissions.ts + test | Claude |
| 2026-08-25 | feat | Visitor paths — the report that was already in the database. The ask was heatmaps of where people go after clicking an ad. Half that question turned out to be answerable with no new capture at all: tracker.js fires verjson('page') on load AND on every SPA navigation, so the ordered page sequence of every visit has been landing in marketing_touchpoints this whole time, already joined to the ad campaign resolved from the UTMs at ingest. The only reader was /attribution/journeys, which demands a visitor_id and returns one person's timeline — a support tool, not a marketing one. New GET /projects/:id/attribution/paths and a /analytics/paths screen close that. Sessionising is where this kind of report goes quietly wrong, so it is a pure function (attribution/paths.ts) with every rule pinned by a test: consecutive duplicates collapse, because an SPA router that replaces state without changing the URL re-fires page and would otherwise make /pricing → /pricing the most common journey on the site; query strings and fragments are stripped so one page is one row rather than one row per ad variant; case is NOT folded, because plenty of servers treat /Pricing and /pricing as different documents; rows with no session_id fall back to the same 30-minute inactivity window the browser SDK uses, so the report cannot disagree with the tracker about what one visit is. The visit is credited to its FIRST touchpoint — the campaign that started it is the one whose money bought it — and the filters apply there too, so a visit that merely wandered through a campaign's landing page on its third page is not counted as something that ad bought. Leaving is an explicit edge (to_path: null) rather than an absence: on a real funnel the largest number is usually the people who left, and a chart that only draws where people WENT reads as though everybody went somewhere. A visit deeper than max_depth deliberately gets no exit edge — a phantom one would report people as having left while they were still reading. Read ceiling of 50k touchpoints, surfaced as truncated: true and as a banner, because a silently truncated report looks exactly like a complete one. UI is a drill-down rather than a Sankey: a Sankey looks impressive and you cannot read a number off it, and the number is the point. +33 tests (26 API incl. the pure sessioniser, 7 web). Verified against 116 seeded visits end to end — every reported figure matches the seeded journey weights. | apps/api/src/modules/attribution/{paths,routes}.ts, packages/contracts/src/attribution.ts, apps/web/src/lib/attribution.ts, apps/web/src/app/(app)/projects/[id]/analytics/paths/page.tsx, apps/api/src/test/attribution-paths.test.ts, docs/{API,FEATURES,VERIFICATION}.md | Claude |
| 2026-08-24 | feat | A client can now pitch in from the calendar — propose a post and a date, without ever reaching the publisher. The ask was "can the client schedule something from their end"; the answer was no anywhere in the app, and the block was one org-role list (WRITE_ROLES = owner|admin|member) checked before project membership, so a client who was a project lead still got 403 on compose. Worse, the calendar UI had no role gating at all — a client reaching that URL saw the full compose and schedule controls and only found out on submit. Opening this up naively would break non-negotiable #3, because create flips variants to scheduled whenever a scheduled_at is present and the publisher acts on that with no reference to any approval. So the distinction the new gate draws is proposing vs scheduling: a client/agency post always lands draft, their chosen time is clamped out of scheduled_at and into the post's proposed_publish_at, and the publisher — which selects on a variant whose status is scheduled — structurally cannot see it. The suggestion becomes real when an approval lands and the existing scheduleApprovedPost promotes scheduledAt ?? proposedPublishAt; that plumbing was already there for calendar-import and agent drafts, so the round trip needed no new machinery. Scope beyond the org role: a proposer may only touch a post they authored, only while every variant is still in a proposal status (once the team schedules it, the content is under a decision already taken), and may withdraw their own — without that last one every mistake would need a team member. The project role stays the second lock, so a client invited as viewer proposes nothing; contributor is the deliberate act that lets them contribute. One new hole closed while opening this: an external contributor cannot decide on their own submission even when a client_review stage names them as an approver — propose-and-approve in two clicks makes the gate a formality for exactly the person it checks. Binds external roles only; an internal author who is also an approver is unchanged. Calendar-side: proposals now render on the grid (dashed, "Proposed — awaiting approval") because a suggestion that lives only in an approvals inbox is invisible on the screen the plan is actually read from — and a proposed date is suppressed once any variant carries a real scheduled_at, or the same post would sit on the grid twice at two different times with no way to tell which one the publisher will act on. The header button reads "+ Suggest a post" and the date modal "Propose date" for external roles. +14 API tests. | apps/api/src/modules/posts/{domain,db}.ts, apps/api/src/modules/approvals/domain.ts, apps/web/src/app/(app)/projects/[id]/calendar/[[...tab]]/page.tsx, apps/api/src/test/posts-proposals.test.ts, docs/{PERMISSIONS,FEATURES,VERIFICATION}.md | Claude |
| 2026-08-22 | feat | Agents page + readable proposals — Batch A of the agents plan closes. Two UI halves of a gate that was already enforced server-side but unusable. (1) Proposals rendered as raw JSON. /approvals printed JSON.stringify(run.proposal) in a <pre>. For Scout and Ideator that was merely ugly; for the Drafter it defeats the gate — a five-draft proposal is ~80 lines of quoted JSON, and a human asked to approve that approves it without reading it, which is exactly the rubber-stamp the state machine exists to prevent. New lib/agent-proposals.ts turns each kind into prose: Scout → what it found + the angles it suggests; Ideator → each idea with hook/angle/why + target channels; Drafter → per-draft blocks with the script and caption side by side, under a one-line summary of what approving does ("Approving creates 2 draft posts on linkedin, instagram. They land as drafts — the usual client approval still runs before anything publishes"). Every parser degrades to null on an unrecognised shape and the page falls back to the JSON view: an unparsed proposal must look unparsed, because a confidently empty summary would understate what is being approved. (2) The launcher became a team. /agent was three numbered steps describing a pipeline; it is now one card per agent — what it does, when it last ran, and Run now — driven by lib/agent-roster.ts. Agents that aren't built (Optimiser, Planner, Site auditor) are listed visibly greyed with the reason, rather than hidden: hiding them makes the page look finished when it isn't. A gated agent explains its gate instead of offering a button that would produce a guaranteed failed run, and a gated agent with several approved parents gets one button per parent — which approved scout to build on is a real choice, not a default we should quietly make. +18 tests (agent-proposals.test.ts 10, agent-roster.test.ts 8) covering the fallbacks, the gates, newest-first candidate ordering and the last-run labels. Web 741/741. | apps/web/src/lib/{agent-proposals,agent-roster}.ts + tests, apps/web/src/app/(app)/agent/page.tsx, apps/web/src/app/(app)/approvals/page.tsx, docs/plans/2026-08-20-studio-agents.md | Claude |
| 2026-08-21 | fix | Facebook post metrics returned nothing at all — one retired metric name was taking the whole row down. Reproduced against a live Page token, not inferred: GET /{post-id}/insights?metric=post_impressions,post_impressions_unique,post_clicks,post_reactions_by_type_total,post_engaged_users answers (#100) The value must be a valid insights metric on every API version from v19.0 to v23.0. Probing name-by-name showed Meta has retired post_impressions, post_impressions_unique and post_engaged_users at post level (also post_views, post_reach, post_activity, post_negative_feedback); post_clicks, post_reactions_by_type_total and post_video_views still answer. Graph rejects the entire request when any one name is invalid, so asking for two live metrics alongside three dead ones failed the whole call — and because an insights failure threw, the comment/share/reaction counts on the post object (which were answering fine the whole time) were never read either. Net effect: every Facebook analytics row blank. Three changes. (1) The metric list is now the verified-live set. (2) Impressions and reach are reported as undefined, not 0 — a zero is a claim ("nobody saw it"), undefined is the truth ("the platform stopped telling us"). (3) The two calls degrade independently: a 400 from insights (the shape a retired metric arrives in — no retry fixes it) leaves clicks blank, records extra.insights_unavailable = 1 for triage, and still returns comments/shares/likes; 401/403/429/5xx still throw, because those are account or transient problems where writing a half-empty snapshot and reporting success would stop the worker ever fetching the real numbers. Likes now prefer reactions.summary(true).total_count from the post object, which answers on Pages where the insights reaction metric returns an empty set. Verified end-to-end through the real adapter against a live post: {likes: 0, comments: 0, shares: 1} where the old code threw. +1 test, 2 rewritten. | apps/api/src/modules/channels/adapters/facebook.ts, apps/api/src/test/adapter-facebook.test.ts | Claude |
| 2026-08-21 | fix | Facebook silently published one image out of however many you attached. Attach five images, approve the post, and the Page showed one — no error, no warning, nothing in the audit trail to say the other four were dropped. The adapter took mediaUrls[0] and discarded the rest, under a comment that said so out loud ("we disallow multi-image here; the first image wins"). A publish that loses content while reporting success is the worst shape this can take, because the post looks fine until someone opens the Page. Now 2-10 images build a real multi-photo post the way Graph requires: each image uploads to POST /{page-id}/photos with published=false (sequential, so a partial failure orphans as few unpublished photos as possible and doesn't burst Meta's per-Page upload limit), then one POST /{page-id}/feed carries them as attached_media[n]={"media_fbid": …}. A failed upload names its position (photo upload 2/5), releases the idempotency marker, and never publishes the partial post. Over ten images is refused with the count rather than trimmed. extra gained post_type: "multi_photo" + media_count; photo_id now only appears on the genuine single-photo path. Single-image and text/link paths are byte-identical to before. Verified the rest of the fleet while here: Instagram (real CAROUSEL, N children + parent) and LinkedIn (multi-image share) were already correct; Threads and Pinterest refuse multi-image with a clear message; X refuses all media (OAuth 1.0a signer still missing). Facebook was the only adapter losing data quietly. +3 tests (multi-photo call sequence + body shape, mid-sequence upload failure publishes nothing, >10 refused). | apps/api/src/modules/channels/adapters/facebook.ts, apps/api/src/test/adapter-facebook.test.ts | Claude |
| 2026-08-21 | feat | The Drafter agent — and approval finally does something (agents plan, Batch A). Two halves of one gap. (1) approve() never applied anything. It moved a run to applied and returned, under a comment saying the apply was "the runner's job, dispatched after this commits" — and nothing dispatched it. Invisible for a year because both live agents (Scout, Ideator) produce text a human reads. New agents/apply.ts executes the approved proposal after the transition commits, and domain.approve records the outcome as an apply step. It never throws into the approver's request: the decision has committed and is not in doubt, so a downstream failure belongs in the run's step log (visible, retryable), not as a 500 on a POST whose primary effect succeeded — pinned by a test that forces the apply to blow up and asserts the run still reads applied with a failed apply step. apply.ts deliberately does not import domain.ts (step-writing stays in the caller) so the two can't form an import cycle. (2) The Drafter (content_draft). Third link in Scout → Ideator → Drafter; the chain previously dead-ended at ideas nobody turned into posts. Same parent gate as ideation and for the same reason — refuses unless the marketing_ideate parent is applied and same-org (a drafter with no approved ideas is the empty proposal the gate exists to prevent). Writes one draft per (idea, channel) pair, capped at 20 so a large idea list can't become a proposal nobody reads before approving; drafts for channels outside the approved ideas are dropped rather than quietly widening the blast radius. Fills the new Post.script alongside the caption, so the client approves the brief before anyone designs the graphic. Approving the writing is not approving the sending: applied drafts land as draft posts with no schedule and no proposed_publish_at, tagged agent-drafted with the run id in internal notes, and still go through the ordinary client-approval flow. Channel → account resolution is conservative — exactly one connected account attaches; zero or several creates the post with no variant and says so in the step log, because guessing between two connected brand accounts is worse than doing nothing. +10 tests (executor refusals incl. cross-tenant, "proposal written, zero posts created", apply-creates-drafts, ambiguous-channel, no-op apply for text-only kinds, apply-failure-keeps-approval). | apps/api/src/modules/agents/{apply.ts,kinds/content-draft.ts,domain.ts,execute.ts}, apps/api/src/test/content-draft.test.ts, docs/plans/2026-08-20-studio-agents.md | Claude |
| 2026-08-21 | feat | Content-calendar bulk import — a month of planned posts from one CSV/Excel upload. POST /projects/:id/calendar-import (multipart) parses a planning sheet into draft posts. Two passes, one code path: dry_run=true (the default — a missing or garbled flag can never write) validates and returns a per-row preview; dry_run=false runs the identical validation and commits. All-or-nothing — one bad row refuses the whole file with every issue listed at once, because a half-imported calendar can't be told from a full one by looking, and re-uploading the corrected sheet duplicates whatever did land. Imported posts are DRAFTS carrying proposed_publish_at, never scheduled_at — nothing in the publish path consults the approval, so importing straight to scheduled would publish a month of unreviewed content on its dates. Fuzzy header matching (Publish Date / publish_at / publishat) with per-column aliases; platform aliases (twitter → x); bare dates read in the project's timezone via fromZonedTime, not UTC (09:30 IST is 04:00Z — getting this wrong shifts an entire calendar), and ExcelJS date cells normalised through the same path so .csv and .xlsx land on one timezone rule. Ambiguity is an error, never a guess: two connected accounts on one platform demands an account_handle column rather than picking a brand. Row cap 500, upload cap 5 MB, legacy .xls refused with a fix-it message, unknown columns reported rather than silently dropped. GET /calendar-import/template.csv generates the starter sheet from CALENDAR_IMPORT_COLUMNS so the template can't drift from the parser. New Post.script + PostRevision.script — the creative brief the client signs off on before graphics are produced; client-visible (it renders in the approval portal) unlike team-only internal_notes, and on CONTENT_FIELDS so editing it invalidates a live approval exactly like editing the caption does. UI at /projects/[id]/calendar/import — template download, file picker, preview table with per-row errors, and a commit button that only lights up when every row is clean. +29 tests (22 API across CSV/Excel/validation/commit/tenancy, 7 web helper). | apps/api/src/modules/calendar-import/{parse,domain,routes}.ts, apps/api/prisma/schema.prisma + migrations/20260821120000_post_script/, packages/contracts/src/{calendar-import,posts,post-revisions}.ts, apps/api/src/modules/posts/{domain,db,serialize}.ts, apps/web/src/lib/calendar-import.ts, apps/web/src/app/(app)/projects/[id]/calendar/import/page.tsx, apps/api/src/test/calendar-import.test.ts | Claude |
| 2026-08-12 | fix | Uploaded media pointed at a host that no longer existed — breaking every thumbnail AND every publish. POST /uploads baked PUBLIC_API_URL into the returned url, and the composer persisted that whole object into Post.media_assets (JSONB). The moment the API's public host changed — a new dev tunnel, a prod cutover — every url ever stored pointed at a dead host, and nothing self-repaired because the value sat inside a blob nobody rewrites. Found live: assets carrying nitrogen-paperback-bouquet-arbor.trycloudflare.com and parameters-acoustic-donation-baptist.trycloudflare.com, both HTTP 000. The visible symptom was broken <img> tiles, but the expensive one was silent: posts/domain.ts hands mediaUrls straight to the adapters, and Meta and LinkedIn fetch those URLs server-side to ingest media — so a publish with a stale host fails at the platform, not in our logs. The url is now derived, never trusted: new core/media.ts owns mediaUrlForKey + withFreshMediaUrls, and the posts serializer rebuilds every media_assets / platform_media entry from its durable S3 key on read. Existing rows heal with no migration. The same helper now backs the upload response and the publish path, which had each grown their own copy of the URL shape. Verified on a real asset: frozen url → HTTP 000, key-derived url → HTTP 200, 2.6 MB image/jpeg. | apps/api/src/core/media.ts, apps/api/src/modules/posts/{serialize,domain}.ts, apps/api/src/modules/uploads/routes.ts | Claude |
| 2026-08-12 | fix | The composer described every platform as if it were LinkedIn. Three bugs from one cause — LinkedIn was the first adapter, and its assumptions were left hardcoded. (1) A Visibility select reading "Public — anyone on LinkedIn" / "Connections only" rendered on every composer, including Instagram's, where it offered a choice that does nothing and named the wrong network doing it; visibility maps to LinkedIn's MemberNetworkVisibility and is read by exactly one adapter. It now renders only when a LinkedIn account is targeted, and default_platform_extra stops stamping the field onto non-LinkedIn variants. (2) The media cap was a hardcoded 20 attributed to LinkedIn — wrong for every platform including LinkedIn (9). The composer accepted 20 assets, warned about nothing, and the server normaliser then clipped to the real per-platform max at publish time, so the post that went out was not the post that was composed. Both guards and the (n/20) header now read PLATFORM_MEDIA_SUPPORT — the same table the normaliser clips against — and the error names the actual platform. (3) Link-preview copy generalised. | apps/web/src/app/(app)/projects/[id]/organic/[platform]/composer-modal.tsx | Claude |
| 2026-08-12 | fix | Videos rendered as an identical grey glyph everywhere. The media library, the composer's Recent strip, its attached-media grid and the preview panel each drew a bare camera icon for any non-image asset — so a project with six clips showed six identical tiles, and a visual picker you cannot pick from is not a picker. All four now render the asset itself with preload="metadata", which fetches the container header and first frame only (no full download) and lets the browser paint a real poster. muted + playsInline stop mobile Safari hijacking a tile into fullscreen playback; the icon survives as an overlay so an undecodable codec still reads as a video. | apps/web/src/app/(app)/projects/[id]/measure/media/page.tsx, apps/web/src/app/(app)/projects/[id]/organic/[platform]/composer-modal.tsx | Claude |
| 2026-08-12 | fix | Next 16 stopped suppressing smooth scrolling during route transitions, so every sidebar click glided to the top instead of landing there. The app sets scroll-smooth on <html> for the landing page's anchor navigation. Next ≤15 silently forced scroll-behavior: auto for the duration of a navigation; Next 16 dropped that override (it cost a style write per navigation) and made it opt-in. Restored with data-scroll-behavior="smooth" on <html> — the documented replacement, per node_modules/next/dist/docs/01-app/02-guides/upgrading/version-16.md. The dev server had been logging the warning on every navigation. | apps/web/src/app/layout.tsx | Claude |
| 2026-08-12 | fix | Disconnecting a social account no longer deletes its analytics. DELETE /social-accounts/:id called prisma.socialAccount.delete(), and PlatformPost.socialAccount is onDelete: Cascade — so every published post through that account, plus every PostMetricSnapshot, PostMetricDaily and AccountMetricSnapshot under it, was destroyed. The confirm dialog said "Draft and scheduled posts to it will be removed too", which described a fraction of what actually went. Disconnect is now a state change: credentials are wiped, connection_status flips to disconnected with a new disconnected_at stamp, and nothing historical is touched. That status is already what a worker sets when a token dies, so metrics/inbox/publish consumers skip it with no new concept. Scheduled variants pause rather than fail — findDueScheduled / findDueRetries now exclude disconnected accounts, because dispatching without credentials could only burn the 5-attempt retry budget and land the post in terminal failed; they resume on reconnect. Reconnect revives the same row: the manual path used to 409 on the composite unique, so the OAuth upsert (which already revived) and the manual path disagreed; both now clear disconnected_at and the published history reattaches by id. New posts to a dormant account are refused 422 at the domain rather than queued as work that can never run, and the composer's channel list filters through a new isPublishableAccount. Permanent deletion survives as an explicit ?purge=true (204, still cascades) behind a confirm that names what it erases; the default control is an unplug icon, and a dormant account carries a "Disconnected · analytics kept" badge. Browser verification caught a real bug in this change: the first cut filtered the analytics panels through publishableAccounts too, so a disconnected account rendered "No Instagram account to analyse yet" — exactly the failure the work set out to fix. +5 API tests. | apps/api/prisma/schema.prisma, apps/api/src/modules/social-accounts/{domain,db,routes}.ts, apps/api/src/modules/publisher/db.ts, apps/api/src/modules/posts/{domain,db}.ts, apps/api/src/modules/channels/routes.ts, apps/api/src/modules/{metrics/worker,inbox/polling,inbox/reply}.ts, apps/api/src/test/social-accounts.test.ts, packages/contracts/src/posts.ts, apps/web/src/lib/social-accounts.ts, apps/web/src/app/(app)/projects/[id]/organic/{[platform]/[[...tab]],instagram/[[...tab]]}/page.tsx, apps/web/src/components/organic-hub-tiles.tsx | Claude |
| 2026-08-11 | feat | AEO Competitors tab — the last stub on the hub (E7 tail). Ships on the AIAnswerSource rows recorded earlier the same day, so it needed no new backend: every check already stores the full ranked source list an engine used, and the competitor set is just that grouped by domain. The peer set is observed, not typed. A hand-maintained competitor list names the rivals you already know about; this one names whoever keeps turning up in the answers to your prompts, which is a different and more useful set. Deliberately distinct from the SEO hub's share-of-voice — a domain can own the AI answer while ranking nowhere on Google. Two tables. Your domains always renders one row per tracked domain including when it was never cited (bestPosition: null → "not cited"): the previous empty-state rollup could only list domains that were actually cited, so a project nobody cites vanished from its own report and the reader hunted for a row that was never going to exist. Who you are up against ranks by engine breadth before raw volume, with share drawn against 100% of all observed sources rather than leader-relative, so a flat field reads as flat instead of making its top row look commanding. New pure ourStanding (+5 tests) and a sharePct on the existing rollup. A test caught the domain normaliser matching only www. and not a scheme — https://www.example.com would have been reported as "not cited" while its citations sat in the table; it now strips scheme, path, query, port and trailing dot. Verified live: 138 observed sources, 51 domains, verjson.com and verjson.ai both listed at 0.0% / not cited, arxiv.org and huggingface.co tied at 15.9% across 4 and 3 engines. IA flipped competitors to implemented: true; tab hint "Soon" → "Live". | apps/web/src/app/(app)/projects/[id]/performance/aeo/{competitors-tab.tsx,[[...tab]]/page.tsx}, apps/web/src/lib/{growth-helpers,growth-helpers.test,project-ia}.ts | Claude |
| 2026-08-11 | fix | The AEO citation tracker never actually ran on its own (E7 tail). The worker scheduled its first sweep one full interval out — setTimeout(runTickSafe, 24h) — so the timer restarted from zero on every deploy and every dev-server reload. A day of work produced 30 worker starts and 0 sweeps, and the only symptom was a UI that said "not checked yet" forever; every result seen so far had been triggered by hand. Any restart cadence faster than the interval means the sweep never fires at all, which for a daily deploy is "never", silently. Same shape sits in the backlinks worker (7-day interval) — flagged, not touched here. Fixed by moving the schedule off process uptime and onto the data: first sweep 30s after boot, interval 24h → 1h. The interval was conflated with "how often to ask an engine"; it only controls how often we go LOOKING for due work, and each prompt's own checkFrequency + lastCheckedAt still decides whether anything is actually asked, so a short interval costs nothing when nothing is due. Adding a prompt now checks it immediately — a prompt is an explicit "tell me about this", and waiting a scheduled sweep for the first answer was never right. New runCheckForPrompt / requestImmediateCheck (fire-and-forget: four engines take ~30s, far too long to hold a request open) behind POST /growth/projects/:projectId/prompts/:id/check → 202, owner/admin only, 409 on a disabled prompt, 404 cross-project. requestImmediateCheck is a no-op under NODE_ENV=test — a detached write would otherwise land after the case that triggered it, racing the next case's TRUNCATE; tests drive runCheckForPrompt directly. UI: per-prompt "Check now" button and a "checking all engines…" state on the row, with the prompts list and both feeds polling at 5s only while a check is in flight. The first cut of that state compared Date.now() in the browser against the server's last_checked_at and hung forever — the server stamps from its own clock, so the value never "overtakes" a locally-taken timestamp. Now it records the last_checked_at visible at request time and waits for it to change, comparing a server value against the same server value. A matching attempt to stamp the row on completion instead was reverted: it broke the tick's injectable clock and two deterministic tests with it, and the change-detection made it unnecessary. +6 API tests. | apps/api/src/modules/growth/{ai-citations,routes}.ts, apps/api/src/main.ts, apps/api/src/test/growth-ai-citations.test.ts, apps/web/src/lib/growth.ts, apps/web/src/app/(app)/projects/[id]/performance/aeo/citations-tab.tsx | Claude |
| 2026-08-11 | feat | AEO: record who did get cited, and stop showing an empty box (E7 tail). Found by using the feature: a real tick ran 16 live checks against four answer engines, found no citation of the tracked domain, and the UI showed the same "No citations yet" it shows before anything has ever run. Two defects behind one symptom. (1) The paid-for answer was being thrown away. The tick only wrote a row if (result.cited), so a check that found nobody stored nothing — despite the provider having just told us the full ranked source list. The project learned nothing and the next check would pay to rediscover the same thing. New AIAnswerSource model records every URL an engine leaned on: model · checked_at · url · domain (bare host, www. stripped — the grouping key) · title · position (the engine's own ranking; source 1 is not source 9) · is_expected, denormalised on purpose so a past observation keeps reporting what was true when it was made even after the expected-domain list is edited. Written by the same tick via one createMany, mock path included, and exposed as GET /growth/projects/:projectId/answer-sources?prompt_id=&model=&from=&to=. (2) "Never checked" and "checked and lost" rendered identically. The empty state now branches: with no observations it says Not checked yet and names the cadence; with observations it says "Checked 4m ago — you weren't cited" and renders Who owns this answer — a per-domain rollup of engines / citations / best position, with the project's own domains marked (you). Ranked by engine breadth before raw volume, because three engines citing you once is a stronger position than one engine citing you three times, and sorting on count alone hides that. New pure helpers summariseAnswerSources + lastCheckedAtFrom (6 web tests) and toAnswerSources (4 API tests). Verified live: a check on "Best AI platform for agriculture and crop management" stored 23 sources across 4 engines and put cropin.com — the domain already tracked as a competitor on the SEO side — at the top with 2 engines and 3 citations. Also fixed a lost space around the em dash: JSX trims line-end whitespace between text and expressions, so the banner rendered "Checked 4m ago— you"; built as one template string instead. Not in scope: the Competitors tab still stubs — this ships the data it needs, not the tab. | apps/api/prisma/schema.prisma, apps/api/src/modules/growth/{ai-citations,routes}.ts, apps/api/src/test/{growth-answer-engines.test.ts,setup.ts}, packages/contracts/src/ai-citations.ts, apps/web/src/lib/{growth,growth-helpers,growth-helpers.test}.ts, apps/web/src/app/(app)/projects/[id]/performance/aeo/citations-tab.tsx | Claude |
| 2026-08-11 | fix | Lint back to zero errors (4 pre-existing failures). npm run lint had been failing, which meant npm run verify could not pass at all. (1) youtube.ts hid a zero-width space (U+200B) inside `bytes */{total}` so the literal would not close its own block comment — invisible, and no-irregular-whitespace rightly objected. Replaced with a plain-ASCII *\/ escape and a note saying why the backslash is there. (2) Unused beforeEach import in ai.test.ts. (3+4) Two setState-synchronously-in-an-effect errors from the campaign-wizard work (wizard-modal.tsx, calendar/[[...tab]]/page.tsx) — both the "reset form state when the modal opens" pattern. Converted to React's documented adjusting-state-during-render form, comparing against the previous open value: React re-runs the component immediately and never commits the stale values, so the field is filled on the first painted frame instead of flashing empty. Behaviour is otherwise identical, and the wizard is no longer reset by a defaultStartDate change while it is already open — the old effect listed it as a dependency, so a parent updating that prop mid-edit would have wiped whatever the user had typed. Browser-verified: opening the wizard from a day cell prefills that date (2026-08-20), cancelling and opening a different day resets the step, clears typed input, and shows the new date (2026-08-03); the schedule modal opens pre-filled with the next round hour. | apps/api/src/modules/channels/adapters/youtube.ts, apps/api/src/test/ai.test.ts, apps/web/src/components/campaigns/wizard-modal.tsx, apps/web/src/app/(app)/projects/[id]/calendar/[[...tab]]/page.tsx | Claude |
| 2026-08-11 | feat | AEO citation tracker now measures reality (E7 tail). fetchAICitationFromProvider had been a throw since the tracker shipped, so every citation in the UI came from checkAICitationMock — a SHA-256 dice roll (30% hit / 15% competitor / 55% miss). Plausible fiction, correctly labelled as such internally, but nothing anyone could report on. It now asks real answer engines real questions. One provider, four engines — everything goes through OpenRouter (OPENROUTER_API_KEY), whose OpenAI-compatible endpoint fronts every model, so measuring four engines costs one integration and one account instead of four. Search is the whole point: a plain chat completion answers from the model's weights and cites nothing, which would read as "nobody cites you" on every prompt. Requests append the :online suffix to buy web search (native for OpenAI/Anthropic/Google, Exa fallback otherwise); Perplexity's Sonar searches natively and is deliberately not given the plugin, which would pay for search twice. Citations arrive as structured url_citation annotations — URL, title, excerpt, order — so cited_url / cited_text / position are read off the provider's own data rather than regexed out of prose. New answer-engines.ts owns the HTTP call and the engine→slug map; the reader (interpretAnswerForCitation) is pure and lives beside the mock it replaces. Domain matching treats subdomains as the tracked domain (blog.example.com cites example.com) with a dot-boundary check so notexample.com does not match, and ignores www./case. An engine that names the domain in prose with no annotation attached still counts — cited_url stays null because there is no URL to record. Stored ids are now the ENGINE, not the model version (chatgpt · claude · gemini · perplexity, was gpt-4o · claude-3-5-sonnet · gemini-1.5-pro · perplexity-online): the customer's question is "does ChatGPT cite us", and engine identity is the only thing stable enough to chart once the underlying model is replaced. Rows written before the switch keep deserialising (model is a free z.string()) and render through a neutral-badge fallback. Two guards. config.growth.aiCitationsMockMode is forced on under NODE_ENV=test — the repo-root .env is injected into the test run, so without it npm test would make live billable calls the moment anyone configured a key (this was caught by a suite that started taking 30s and hitting the network). And AI_CITATIONS_MAX_CALLS_PER_TICK (default 200) caps a tick on a whole-prompt boundary, logging what it deferred rather than silently truncating. Slug selection is evidence-based, not vibes: a live smoke found google/gemini-3.6-flash:online returns HTTP 200, a good answer, and zero annotations — which this tracker would have faithfully recorded as "Gemini never cites anyone". 3.5-flash and 3-flash-preview do the same. Gemini therefore sits on 2.5-flash (5 annotations, verified); the finding and the full measurement table are written into answer-engines.ts so nobody "upgrades" into a silent false negative. New npm run smoke:engines --workspace @verjson/api re-runs that check and exits non-zero if any engine returns no sources. +17 API tests over the reader, the annotation parser, the domain matcher and the test-mode guard. | apps/api/src/modules/growth/answer-engines.ts, apps/api/src/modules/growth/ai-citations.ts, apps/api/src/core/config.ts, apps/api/scripts/smoke-answer-engines.ts, apps/api/src/test/growth-answer-engines.test.ts, packages/contracts/src/ai-citations.ts, apps/web/src/lib/growth-helpers.ts, apps/web/src/app/(app)/projects/[id]/performance/aeo/citations-tab.tsx, .env | Claude |
| 2026-08-10 | feat | Campaign creation wizard + calendar click-to-schedule (F-023 · F-021 · F-024). New 4-step wizard replaces the single-page create modal: Step 1 (Type) picks one of organic · paid · mixed · influencer · event · launch · other — a shape hint that steers the wizard but never restricts what a campaign can do; Step 2 (Basics) captures project, name, one-line objective, brief, start & end dates; Step 3 (Channels) shows the project's marketing mix pre-filtered by the type hint (an organic campaign hides paid-only channels behind a "Show all" toggle, paid hides organic ones, mixed/other show everything) — the "channels must subset the project's mix" server rule remains the only hard constraint; Step 4 (Review) is a one-glance summary before create. New CampaignType Prisma enum (campaign_type) + type column on campaigns with default(other) so old rows read back cleanly; contract adds CAMPAIGN_TYPES, campaignTypeSchema, CAMPAIGN_TYPE_META, and threads type through create + update + read schemas + serializer. Calendar hook-up — each day cell in the project month grid is now a clickable button that opens the wizard with defaultStartDate pre-filled to that day (an absolute-positioned button sits behind the scheduled-post cards, z-0, so post-card clicks still land on the card — anywhere else in the cell opens the wizard). A "+ New campaign" button lives in the calendar header for the no-date case. Same wizard powers the org-wide /campaigns page — the older create-modal.tsx stays in the tree unused for a beat before deletion so a rollback is one import swap. 5 new API vitest cases: create defaults type to other, explicit type persists, unknown type is 422 at the contract layer (not silently coerced), update accepts a type change. Zero web unit-test churn — the wizard is a composition on top of the already-tested useCreateCampaign hook + ChannelPicker. | packages/contracts/src/campaigns.ts, apps/api/prisma/schema.prisma, apps/api/src/modules/campaigns/{domain,serialize}.ts, apps/api/src/test/campaigns.test.ts, apps/web/src/components/campaigns/wizard-modal.tsx, apps/web/src/app/(app)/campaigns/page.tsx, apps/web/src/app/(app)/projects/[id]/calendar/[[...tab]]/page.tsx | Claude |
| 2026-08-10 | feat | Real Google Ads, Meta Ads, X and YouTube adapters (PR #116). Four adapters moved from "shell + mock" to platform-real. Google Ads — updateCampaignBudget resolves each campaign's shared campaign_budget resource via googleAds:searchStream, then mutates amount_micros (daily) or total_amount_micros (lifetime) with the right update_mask; updateAdBid reads each ad group's cpc_bid_micros, scales by multiplier, floors at 1, and refuses if no ad group under the target carries a manual bid (ad groups on automated bidding are skipped — silently doing nothing would let an approved proposal report success having moved no spend). New minorToMicros bridges our minor-units convention to Google's micros. Meta Ads — updateCampaignBudget writes daily_budget/lifetime_budget on the campaign (CBO-only; without Advantage campaign budget Meta keeps money on the ad set and 400s, mapped to invalid_request); updateAdBid scales each ad set's bid_amount, again skipping automatic-bid ad sets and throwing when nothing manual remains. X (Twitter) — OAuth AUTHORIZE_URL moved to x.com/i/oauth2/authorize; the legacy twitter.com host 302s to x.com and the browser drops the OAuth session across the redirect, yielding X's generic "You weren't able to give access to the App" page (server-to-server calls remain on api.twitter.com). YouTube — real-mode video publish via Google's resumable-upload protocol: session-init POST with X-Upload-Content-Length returns an opaque Location URL; 8 MiB PUT chunks with Content-Range; resync off Google's Range: bytes=0-{n} header rather than trusting our own cursor; 5xx-per-chunk retry via a bytes */{total} status probe, capped at MAX_CHUNK_ATTEMPTS=3. Reused for later videos: checkAndRemember/rememberSuccess/forget idempotency. Title floored at 100 chars, description at 5000, default category 22 ("People & Blogs"). Optimisation-proposal apply now uses real tokens — ads/proposals/domain.ts::apply reads the ad account's encrypted oauthAccessToken via decryptToken(...) and passes it to the adapter, falling back to the literal "mock-token" (which mock adapters ignore) only when the account has none; a real adapter handed that literal correctly fails at the platform with a 401, so a never-authorised account no longer reports a phantom success. Connect-guide for YouTube rewritten to flag the two real ceilings: unaudited apps have every upload forced to private regardless of request, and the daily 10 000-unit quota means ~6 real publishes/day (uploads cost 1 600 units each). +512 lines of new tests across adapter-google-ads.test.ts (new, 151 lines), adapter-meta-ads.test.ts (new, 125 lines), and adapter-youtube.test.ts (+236 lines exercising the resumable-upload happy path, non-256 KiB-aligned chunking, 5xx-then-resume, and quota-exceeded surface). 1 189 insertions / 61 deletions across 12 files. F-043 · F-050 · F-051 · F-056 flipped 🔲 → 🟡. | apps/api/src/modules/ads/adapters/{google-ads,meta-ads}.ts, apps/api/src/modules/ads/proposals/domain.ts, apps/api/src/modules/channels/adapters/{x,youtube}.ts, apps/api/src/modules/channels/bootstrap.ts, apps/api/src/core/config.ts, apps/api/src/test/adapter-{google-ads,meta-ads,x,youtube}.test.ts, apps/web/src/lib/connect-guides.ts | Claude |
| 2026-08-05 | feat | Campaign → post → calendar loop closed (PR #93). Data model already had Post.campaign_id (Prisma FK, contracts, GET /posts?campaign_id= filter, calendar badge) but the UI never let a user tag a post to a campaign, and /campaigns/[id] was four PlaceholderCard "COMING UP" tiles. Now: composer has a Campaign <select> above Visibility+Schedule (filters out archived+completed; new defaultCampaignId prop for pre-tagged callers); /campaigns/[id] shows a real rollup — scheduled posts with status pills + relative datetimes + link into the filtered calendar, plus an ad-launches pointer card; calendar reads ?campaign=<id>, threads it to usePosts, renders a visible primary-tone filter chip with a Clear link, preserves the query param across Month/Week/Content/Ads view flips. Zero backend change — endpoint already accepted the filter. 247 / 30 lines across 3 files. | apps/web/src/app/(app)/campaigns/[id]/page.tsx, apps/web/src/app/(app)/projects/[id]/calendar/[[...tab]]/page.tsx, apps/web/src/app/(app)/projects/[id]/organic/[platform]/composer-modal.tsx | Claude |
| 2026-08-05 | fix | First-run path repaired — signup, lint, and the documented setup command (B-011…B-014). Found by running the app rather than the suite: every one of these sits on signup → login → first project, and none was caught by ~2,100 green tests. B-011 (blocker) — POST /auth/signup-with-org set user.role = "owner" but never wrote the UserRoleAssignment the policy engine actually reads, so every real signup produced an owner with zero permissions and no in-app way out. The legacy POST /auth/signup had the write; the Better-Auth cutover added a second path and the E12 RBAC slice only back-filled the first. Test helpers sign up through the legacy route, which is why the suite stayed green. Fixed inside a transaction with compensating deletes, and covered by a new suite that asserts the resulting permission set (3 of 4 cases fail against the old code). B-012 — /login hydration error from a render-time window feature-detect. B-013 — db:push/seed failed from a clean clone because nothing bridged the root .env to apps/api; new prisma.config.ts (which also retires the deprecated package.json#prisma block) plus --env-file-if-exists on the tsx scripts. B-014 — lint was failing with 14 errors against a documented "0 errors": 7 were the Hono boundary rule's allowlist matching only the bare filename routes.ts while modules had split into *-routes.ts siblings; the code was right and the rule was stale. New npm run rbac:backfill supplies the script the API's own boot warning had been telling people to run since 2026-08-04. | apps/api/src/modules/auth-v2/routes.ts, apps/api/src/test/signup-with-org.test.ts, apps/api/prisma/{backfill-rbac.ts}, apps/api/prisma.config.ts, apps/api/eslint.config.mjs, apps/api/package.json, apps/web/src/app/(auth)/login/login-form.tsx, apps/web/src/lib/auth-passkey.ts, docs/{BUGS,VERIFICATION,STATUS}.md | Claude |
| 2026-08-05 | feat | Per-project Integrations hub + Activity feed + KPIs redirect (batch 20 T1, PR #91). New /projects/:id/integrations unifies the 5 shipped connection hooks (social / ad / analytics / slack / webhook) into a single hub with status pills + Manage/Reconnect/Connect CTAs — client-side union, no new backend. New /projects/:id/activity per-project audit feed backed by a project_id query filter added to /audit-logs (matches entity_id = projectId OR meta.project_id = projectId — pragmatic over an FK-column migration). /projects/:id/kpis → server-side redirect to /analytics; KPIs section relabelled "KPIs & Analytics" for clarity. +14 web + 1 API tests → 514 web green. | apps/web/src/app/(app)/projects/[id]/{integrations,activity,kpis}/page.tsx, apps/web/src/lib/{integrations-hub,project-activity}.ts, apps/api/src/modules/audit/routes.ts | Claude |
| 2026-08-05 | feat | URL-routed section tabs (batch 20 T2, PR #90). 8 sections converted to Next 16 optional-catch-all [[...tab]]/page.tsx (Organic hub, SEO, AEO, Google Ads, Meta Ads, Campaigns, Calendar, Approvals). Sidebar now renders children as nested links driving the active tab from params.tab[0] — ~50 previously-placeholder child slugs stop hitting the "Building this next" catch-all and open the correct tab instead. AdPlatformHub converted to controlled tab state so Google + Meta Ads share the same component with URL-driven active-tab. Approvals gained a live-count filter bar. New pure apps/web/src/lib/section-tabs.ts — tabForChildSlug / defaultTabFor / isValidTabForSection. +19 helper tests → 442 web green. | apps/web/src/app/(app)/projects/[id]/{organic,performance/{seo,aeo,google-ads,meta-ads},performance/campaigns,calendar,approvals}/[[...tab]]/page.tsx, apps/web/src/lib/section-tabs.ts, apps/web/src/components/app-shell/sidebar.tsx | Claude |
| 2026-08-05 | feat | Connection health helpers + audit-feed polish (batch 20 T3, PR #89). Pure apps/web/src/lib/connection-health.ts — computeConnectionHealth(input, now) classifies every shipped connection kind (social / ad / analytics / slack / webhook) into a discriminated union healthy / degraded(reason) / failed(reason) / not_connected from the wire-shape rows the existing hooks already return; reads SocialAccountRateLimit (batch 13) when passed alongside a SocialAccount; terminal token failures short-circuit rate-limit branch; analytics stale threshold 24h; webhook degrades above 3 consecutive failures. Presentation helpers healthTone, healthLabel, healthActionCta, formatRetryAt. New <ConnectionHealthPill> component. Settings audit page rewritten with chip multi-select filters, preview drawer (click row to open, Escape closes), and CSV export (current page or walked cursor to 32-page cap). New apps/web/src/lib/audit-helpers.ts — diff extraction, RFC-4180-ish CSV quoting, multi-select serialisation, useDebouncedValue. +77 web helper tests → 500 web green. | apps/web/src/lib/{connection-health,audit-helpers}.{ts,test.ts}, apps/web/src/components/{integrations/connection-health-pill,audit/*}.tsx, apps/web/src/app/(app)/settings/audit/page.tsx | Claude |
| 2026-08-05 | feat | E5 — GA4 + Google Search Console ingestion + cross-source reconciliation (batch 19 T1). Closes the three 🔲 rows on E5 (GA4, GSC, reconciliation). New Prisma models AnalyticsProviderConnection (project × provider × externalPropertyId — provider enum ga4/gsc; AES-256-GCM tokens via core/crypto purpose analytics-oauth; status enum connected/token_revoked/disabled) + AnalyticsMetricSnapshot (per-day per-dimension; nullable GA4 columns + nullable GSC columns; unique [connectionId, day, dimension, dimensionValue]). Two new NotificationKinds (analytics_provider_connected, analytics_token_revoked) wired in all four spots (Prisma enum + packages/contracts/src/notifications.ts + tray + KIND_META/CATEGORIES under new "Analytics" bucket). New module apps/api/src/modules/analytics/ — mock-first AnalyticsAdapter interface + registry (kept distinct from channel-adapters); adapters/ga4.ts (Data API v1beta runReport per-dimension × 3 + Admin API v1beta accountSummaries) + adapters/gsc.ts (Search Console v3 sites + searchAnalytics/query) both behind mock-vs-real factories (identical pattern to modules/ads/adapters/google-ads.ts) that fall back to SHA-256-seeded deterministic mocks when ANALYTICS_MOCK_MODE=1 OR when GOOGLE_ANALYTICS_CLIENT_{ID,SECRET} are unset; oauth.ts with analytics-oauth audience distinct from channel-oauth (no cross-flow replay). New endpoints (per-route requireAuth, mounted at /): GET /projects/:id/analytics-connections, POST /oauth/analytics/:provider/start, GET /oauth/analytics/:provider/callback (public — signed state carries userId; upserts on (project, provider, NULL) since Postgres treats NULL distinct in unique indexes → manual find+update+create), GET /projects/:id/analytics-connections/:cid/properties, POST /projects/:id/analytics-connections/:cid/properties/select, DELETE /projects/:id/analytics-connections/:cid, GET /projects/:id/analytics-metrics?provider=&from=&to=&dimension=, GET /projects/:id/analytics-reconciliation?from=&to=. Ingestion worker startAnalyticsWorker — 6h tick with 1h per-connection min-interval; pulls yesterday's day; token_revoked/token_expired → sole-writer updateMany on status=connected to token_revoked + one-shot analytics_token_revoked notification to connectedByUserId; rate_limited/invalid_request stash lastError but keep status connected; skip-in-test. Pure computeReconciliation walks four sources — GA4 (per-day sessions/conversions/revenue rolled from source dimension), GSC (per-day impressions/clicks), platform-native (PostMetricDaily engagements + impressions + clicks across every social account on the project), attribution (batch 17 T1 AttributionCredit model=last_touch → conversions + revenue = valueMinorUnits/100) — emits per-(day, metric) rows with drift_pct = (max-min)/max and drifted flag on drift_pct > 0.15 (config default). UI shipped at apps/web/src/app/(app)/projects/[id]/performance/analytics-providers/page.tsx — connection cards per provider (empty state + status pill + "Connect GA4" / "Connect Search Console" CTAs), property-picker modal (post-connect + change), metrics table with dimension/provider filters, reconciliation table with drift ribbon + per-row severity badge. New apps/web/src/lib/analytics-providers.ts with hooks + pure classifiers (driftSeverity, providerDisplayName, dimensionLabel, formatMetricValue, reconciliationSourceLabel). Sidebar entry flipped implemented: true in project-ia.ts. +89 API vitest cases (analytics-oauth, analytics-ga4, analytics-gsc, analytics-worker, analytics-routes, analytics-reconciliation) + 19 web helper tests. Non-scope: no GA4 real-time endpoint, no custom-dimension support (three GA4 defaults hard-coded), no cross-provider visitor dedup, no Looker export, no GA4-window attribution model overrides. | apps/api/prisma/schema.prisma, apps/api/src/core/config.ts, apps/api/src/modules/analytics/**, apps/api/src/test/analytics-*.test.ts, apps/api/src/test/setup.ts, apps/api/src/{app,main}.ts, packages/contracts/src/{analytics,notifications,index}.ts, apps/web/src/app/(app)/projects/[id]/performance/analytics-providers/page.tsx, apps/web/src/lib/{analytics-providers,analytics-providers.test,notifications,project-ia}.ts, apps/web/src/components/notification-tray.tsx, docs/CHECKLIST.md | Claude |
| 2026-08-04 | feat | E12 — WebAuthn / passkeys + full MFA settings UI (batch 14 T2). Closes the two ⬜ rows on E12: passkeys as a second-factor option and the Settings → Security UI panel for both TOTP and passkeys. @simplewebauthn/server@^10 on the API, @simplewebauthn/browser@^10 + qrcode.react@^4 on the web. New Prisma model UserWebAuthnCredential (base64url credentialId + Bytes COSE public key + monotonic signCount for anti-clone + transports[] + deviceType + backupState + user nickname + lastUsedAt). New RP config in core/config.ts (WEBAUTHN_RP_ID, WEBAUTHN_RP_NAME, WEBAUTHN_ORIGINS). New backend module apps/api/src/modules/mfa/webauthn/* — challenge-cache.ts (in-memory 5-min TTL, consume-once, hourly unrefed sweep; single-replica caveat documented for a Redis follow-up), db.ts (sole-writer signCount update via signCount < newCount filter — race-safe against browser retries), domain.ts (start/finish registration + authentication + list/rename/delete). New endpoints (per-route requireAuth, mounted at /): POST /mfa/webauthn/register/{start,finish}, POST /mfa/webauthn/authenticate/{start,finish} (mints a satisfied-token with method="webauthn" reusing batch 12 T1's mfa-satisfied audience), GET /mfa/webauthn/credentials, PATCH /mfa/webauthn/credentials/:id (rename), DELETE /mfa/webauthn/credentials/:id?force=true (409 on the last MFA factor unless force; a forced last-factor removal also fires mfa_disabled audit + notification, mirroring the TOTP disable path). Extended POST /mfa/challenge/verify — Zod-refined mutex between code and webauthn_response; a passkey now satisfies the step-up gate without a separate flow. New POST /mfa/recovery-codes/regenerate — gated by requireMfa so a compromised session can't invalidate the owner's real recovery codes. GET /mfa/status now includes webauthn_credentials_count. Full audit trail: webauthn_credential_registered/_removed, webauthn_registration_failed, webauthn_authentication_{succeeded,failed}, mfa_recovery_codes_regenerated. Two new NotificationKinds + tray records (Shield): webauthn_credential_added / webauthn_credential_removed. UI shipped at apps/web/src/app/(app)/settings/security/{page,totp-panel,passkeys-panel}.tsx — Passkeys panel with add via SimpleWebAuthn browser flow + inline rename + delete-with-confirm; TOTP panel with modal QR enrollment (qrcode.react), one-time recovery-code reveal panel, disable flow, and a regenerate flow that runs the challenge/verify dance in-line before hitting the requireMfa-gated endpoint. New hook file apps/web/src/lib/mfa.ts (mirrors lib/api-keys.ts style; includes pure helpers formatOtpauthLabel / describeCredentialTransports / sortCredentialsForDisplay). +33 API vitest cases (webauthn-registration.test.ts, webauthn-authentication.test.ts, mfa-status-webauthn.test.ts — @simplewebauthn/server mocked at the module boundary) + 11 web helper tests → 1037/1037 API + 222/222 web. Non-scope: no sign-in-with-passkey (first-factor is a future slice), no FIDO2 admin policy, no SMS-based MFA, no hardware-key attestation verification. Also fixed a pre-existing web test failure by adding approval_reminder + approval_portal_link_created to the Approvals notification category (they were falling through to Other). | apps/api/prisma/schema.prisma, apps/api/src/core/config.ts, apps/api/src/modules/mfa/**, apps/api/src/test/{webauthn-registration,webauthn-authentication,mfa-status-webauthn}.test.ts, apps/api/src/test/setup.ts, packages/contracts/src/{mfa-webauthn,notifications,index}.ts, apps/web/src/app/(app)/settings/security/{page,totp-panel,passkeys-panel}.tsx, apps/web/src/lib/{mfa,mfa.test,notifications}.ts, apps/web/src/components/notification-tray.tsx | Claude |
| 2026-08-04 | feat | E9 — X (Twitter) adapter. Sixth channel adapter shipped: connect + text publish + post-metrics. apps/api/src/modules/channels/adapters/x.ts. OAuth 2.0 with PKCE (X requires it — the E9.1 harness already emits the verifier/challenge pair) at twitter.com/i/oauth2/authorize; token exchange at POST api.twitter.com/2/oauth2/token uses Basic auth with code_verifier in the form body — LinkedIn's confidential-client shape with the PKCE twist. Scopes: tweet.read tweet.write users.read offline.access (the last is what enables real refresh). publishPost: text-only via POST /2/tweets (280-char cap enforced); threaded reply via content.extra.replyToTweetId; video refused. Media publishing is stubbed on purpose — v1.1 media/upload.json requires OAuth 1.0a HMAC signing (materially different from the OAuth 2 stack), so real-mode publish with images refuses invalid_request with a message naming the missing OAuth 1.0a signer. Mock mode (X_MOCK_MODE=1) returns deterministic SHA-256-seeded media_id_strings so the composer/publisher wiring is smoke-testable end-to-end without paying for the API tier that even grants analytics access. getPostMetrics: GET /2/tweets/:id?tweet.fields=public_metrics — the standard PostMetrics fields map cleanly except that X splits reshare into retweet vs quote-tweet, which we sum into shares and keep raw in extra. Error mapping: 401/403→token_revoked, 429→rate_limited (honours x-rate-limit-reset Unix-seconds header when computing retryAfterSec), 5xx→platform_error, 400→invalid_request. Bootstrap registers x when both X_CLIENT_ID + X_CLIENT_SECRET are set; UI Connect guide flipped to oauth_ready with prereqs that call out the media-stub caveat plainly. +25 tests → 806/806 (adapter suite grew 67 → 92 across pinterest/channels/x). | apps/api/src/modules/channels/adapters/x.ts, apps/api/src/modules/channels/bootstrap.ts, apps/api/src/core/config.ts, apps/api/src/test/adapter-x.test.ts, apps/web/src/lib/connect-guides.ts, docs/CHECKLIST.md, .env.example | Claude |
| 2026-08-04 | feat | E10 — project KPI rollup engine + read API. Closes the "individual metric tables exist but nothing rolls them into a project view" gap. New ProjectKpiSnapshot model ([project, day, channel, platform] unique; "" empty-string sentinel for cross-platform aggregates because Postgres treats NULLs as distinct in unique indexes) — one row per (channel, platform) per UTC day plus a grand total row ALWAYS emitted (even for a zero-signal day, so a gap in the chart is a real "not yet computed" rather than "the day was silent"). Pure computeKpiSnapshotsForProjectDay reads PostMetricDaily (organic engagement + count of publishedAt-in-day PlatformPosts), AccountMetricSnapshot (per-account follower delta: last-of-today minus last-of-yesterday, per platform), and AdMetricSnapshot (spend + impressions + clicks + conversions per campaign per day + distinct active campaign count). Idempotent by shape — the caller upserts on the composite key. New 6-hourly startKpiRollupWorker (KPI_ROLLUP_TICK_MS, config in core/config.ts) walks every project, rolls yesterday, and triggers a catch-up (capped at 30 days) when the latest snapshot is more than 3 days behind. Read API: GET /projects/:id/kpis (items + server-summary; channel/platform filters), GET /projects/:id/kpis/trends?metric=…&granularity=day\|week (Monday-start ISO weeks; zero-filled buckets), POST /projects/:id/kpis/recompute?day=YYYY-MM-DD (owner/admin only, 422 on bad day). All per-route requireAuth behind the router mounted at / (E5.1 lesson). Contracts in packages/contracts/src/kpis.ts. Wire uses null for cross-platform aggregate platform; storage uses "" — bridged in the serialiser. +42 new tests → 715/715 pass. | apps/api/prisma/schema.prisma, apps/api/src/modules/kpis/**, apps/api/src/core/config.ts, apps/api/src/main.ts, apps/api/src/app.ts, apps/api/src/test/kpis-*.test.ts, apps/api/src/test/setup.ts, packages/contracts/src/{kpis,index}.ts | Claude |
| 2026-08-03 | feat | E5.1 — LinkedIn post-metrics ingestion (PR #14). Every published LinkedIn post now shows a live metric strip (❤ · 💬 · fetched Nm ago) that refreshes as the worker polls. New PostMetricSnapshot model (time-series per PlatformPost: impressions/reach/likes/comments/shares/saves/clicks + raw). LinkedIn getPostMetrics implemented against /v2/socialActions/{urn} (likes + first-level comments; impressions are Page-scope only — deferred to linkedin-company). New apps/api/src/modules/metrics/ worker (15-min tick via METRICS_TICK_MS, 1-hour per-post min-interval, 30-day retention). Failure discipline mirrors publisher: token_revoked/token_expired marks account disconnected, rate_limited skips-until-next-tick. GET /posts/:id/metrics + usePostMetrics hook (60s refetch, published-only) + MetricsStrip on PostRow. Bug caught by tests: middleware leak on / mount broke better-auth's own auth flow — fixed by per-route requireAuth. +10 tests → 255/255. | apps/api/prisma/schema.prisma, apps/api/src/modules/metrics/**, apps/api/src/modules/channels/adapters/linkedin.ts, apps/api/src/test/metrics.test.ts, apps/web/src/lib/posts.ts, apps/web/src/app/(app)/projects/[id]/organic/[platform]/page.tsx | Claude |
| 2026-08-03 | feat | E3.1 — publisher orchestrator (PR #13). "Schedule for tomorrow 9am" now actually fires at 9am instead of parking the row forever. New apps/api/src/modules/publisher/ — tick loop (default 30s via PUBLISHER_TICK_MS) finds due scheduled PlatformPosts and dispatches each. Atomic claim via the existing sole-writer scheduled → publishing transition (safe under N API replicas — zero double-publish risk). Uses the SAME code path as the "Publish now" button (extracted dispatchClaimedPublish + PublishFailure in posts/domain.ts so both surfaces share behaviour). Basic failure classification: token_revoked/token_expired → also flip the SocialAccount to disconnected; other errors → row lands at failed with publish_error stashed (user can Publish now to retry). SIGTERM: flips a drain flag, cancels next tick, waits (up to 30s) for in-flight publish(es) before exit — no half-posts on rolling deploys. Every attempt audit-logged (action: "publish" \| "publish_failed", via: "scheduler"). Deferred to E3.2+: retry queue with exponential backoff, per-account rate-limit tracking, full 90-day request/response audit. +8 tests → 245/245. | apps/api/src/modules/publisher/**, apps/api/src/modules/posts/domain.ts (refactored to expose dispatchClaimedPublish + PublishFailure), apps/api/src/main.ts, apps/api/src/test/publisher.test.ts | Claude |
| 2026-08-03 | feat | E9.3 — publish path + media pipeline + composer rewrite (PR #11). Closes the "connected but can't publish" gap. New POST /uploads (multipart → MinIO with per-kind size caps: image 8 MB, video 200 MB; whitelisted content types) + GET /uploads/:key gateway so LinkedIn/etc. can download bytes without exposing S3. New POST /posts/:postId/platform-posts/:id/publish — draft/failed → publishing → published (or failed) through the sole-writer state machine; rollback to failed on adapter throw so no row stays stuck. LinkedIn adapter — full media parity: single & multi-image, single video, link preview, visibility (PUBLIC / CONNECTIONS). Register-upload → PUT bytes → include URN dance per asset with the right feedshare recipe per kind. Caps: 20 images OR 1 video, no mixing. Schema: Post.media_assets (JSONB) + Post.link_url. Contract: MediaAsset + default_platform_extra on create (seeds every child variant's platform_extra so composer-level visibility choices land where publish reads them). Composer rewrite: drop-zone file picker with drag-drop, thumbnail grid + per-item remove, link URL input, visibility select styled to the design system. Modal primitive refactored to header + scrollable body + footer with max-h-[calc(100vh-4rem)] and a footer?: ReactNode prop. "Publish now" button on draft/failed PostRow. 237/237. | apps/api/src/modules/uploads/**, apps/api/src/modules/posts/{routes,domain}.ts, apps/api/src/modules/channels/adapters/linkedin.ts, apps/api/prisma/schema.prisma, packages/contracts/src/posts.ts, apps/web/src/lib/posts.ts, apps/web/src/app/(app)/projects/[id]/organic/[platform]/composer-modal.tsx, apps/web/src/components/ui/modal.tsx | Claude |
| 2026-08-03 | feat | E9.2 — Instagram + LinkedIn OAuth adapters + connect flow end-to-end (PR #10). Real OAuth adapters plug into the E9.1 framework. Meta: short→long token exchange, walks /me/accounts to pick the FB Page with a linked IG Business account, stashes PAGE access token + IG_USER id in providerMeta so publish/metrics never repeat the walk. LinkedIn: OAuth2 + OIDC /v2/userinfo for profile; refresh supported when the app has the Refresh Tokens product enabled. Registry auto-registers whichever platforms have META_* / LINKEDIN_* env at boot. New GET /channels public list endpoint powers a "Sign in with X" primary CTA on every organic hub (falls back to the manual modal when the platform's adapter isn't configured); success/failure banner on return from the OAuth callback (?connected= / ?oauth_error=), URL params scrubbed after read. Option A (same PR): connected_by_user_id nullable FK on SocialAccount — accounts stay project-shared for posting but token-death alerts / reconnect prompts go to the user whose grant powers it, not a random teammate. Publish/metrics/inbox for both platforms stubbed not_implemented — pending C2 / F-041. Proven live: real LinkedIn OAuth → account row in DB with AES-256-GCM-encrypted tokens. +14 tests → 237/237. | apps/api/src/modules/channels/adapters/{instagram,linkedin}.ts, apps/api/src/modules/channels/bootstrap.ts, apps/api/src/modules/channels/routes.ts (adds GET /channels), apps/api/prisma/schema.prisma (adds connected_by_user_id), apps/web/src/lib/social-accounts.ts, apps/web/src/app/(app)/projects/[id]/organic/[platform]/page.tsx, .env.example | Claude |
| 2026-08-03 | feat | E9.1 — channel-adapter framework (PR #9). The abstraction one publisher/poller needs to talk to many platforms. Ships the seams only — per-platform adapters land in E9.2. ChannelAdapter interface + full type vocabulary (PublishContent, OAuthTokens, AccountProfile, PublishResult, PostMetrics, AccountMetrics, InboxMessage, typed AdapterError codes). Registry with duplicate refusal + test-only reset. Credential vault (AES-256-GCM via core/crypto.ts, purpose-namespaced HKDF-derived key, preserves NULL for no-refresh-token). OAuth harness (oauth.ts + routes.ts): JWT-signed state with channel-oauth audience + 5-min TTL, optional PKCE, generic POST /oauth/:adapterKey/start + GET /oauth/:adapterKey/callback that dispatch by URL segment. Upserts on (project, platform, accountPlatformId) so re-connect rotates tokens rather than duping; refresh only overwritten when the platform returned one (avoid clobbering with null). Schema: oauth_access_token / oauth_refresh_token / token_expires_at / oauth_scope / provider_meta on social_accounts_connected. Design clean-room from brightbean's providers/base.py + types.py. +15 tests → 223/223. | apps/api/src/modules/channels/{types,registry,vault,oauth,routes}.ts, apps/api/prisma/schema.prisma (OAuth token columns), apps/api/src/test/channels.test.ts | Claude |
| 2026-08-03 | feat | IA restructure — the whole target sitemap is walkable end-to-end. Top nav trimmed from seven flat items (Projects · Calendar · Campaigns · Approvals · KPIs · Integrations · Billing) to the four IA top-level sections (Projects · Billing · Team · Settings); everything marketing-workflow lives inside a project now. New /projects/[id]/layout.tsx with grouped sidebar (Overview / Organic × 6 / Performance Marketing × 4 / Plan & Approve / Measure / Connect) and a project header with breadcrumb. Full-width layout — dropped max-w-7xl caps so the sidebar + content grid uses the whole viewport; individual copy blocks keep their own reading-column caps. One catch-all placeholder page ([...ia]/page.tsx) renders every un-implemented section — resolves at the section level (not per-child) so we ship ~20 sections instead of ~90 sprawling leaves, and shows the children as a preview tab-bar ("What this will have: Posts / Reels / Stories …"). Real pages take precedence automatically — creating projects/[id]/<section>/page.tsx displaces the catch-all. Campaigns list moved under project. Calendar UI (B1) shipped — monthly grid on top of E2 scheduling, timezone-local rendering, cards colored by PLATFORM_POST_STATUS_META tone. E2 scheduling engine (PostingSlot + Queue + QueueEntry + nextSlotDatetimes + SELECT FOR UPDATE on SocialAccount) came with this shipment via cherry-pick. Suite 168 → 201/201. | apps/web/src/app/(app)/**, apps/web/src/lib/project-ia.ts, apps/web/src/components/redirect-to-first-project.tsx, apps/api/src/modules/scheduling/**, apps/api/prisma/schema.prisma, apps/api/src/test/scheduling.test.ts | Claude |
| 2026-08-03 | feat | Campaigns — the unit content, calendar entries and ad launches will hang off (F-023). Nav pointed at /campaigns since day one and 404'd — this ships the whole slice end-to-end: Prisma Campaign model + CampaignStatus enum (draft / scheduled / live / paused / completed / archived) on the existing Project FK, shared contract in @verjson/contracts/campaigns.ts (schemas + CAMPAIGN_STATUS_META), API module modules/campaigns/ (mirrors modules/projects/: db, domain, serialize, routes) with tenant + project-membership scope on every query, and a full /campaigns list + /campaigns/[id] detail page. Two rules the schema can't express are enforced in the domain: channels must be a subset of the parent project's mix (a channel the project doesn't invest in cannot be a campaign's channel — the roll-up would otherwise inflate silently); and status transitions follow a table — completed → live is refused, archived has no outbound edge, so a report a campaign appears in can't be invalidated by a stray write. The create modal mirrors the same subset-constraint client-side by disabling forbidden channels rather than validating after the fact. 10 new behavioural tests cover CRUD, cross-tenant isolation (out-of-org 404 not 403 — no oracle for "does this campaign exist"), project-scope for agencies, channels-subset enforcement, date-order refusal, and the illegal-transition wall. Full suite 158 → 168/168. Verified live: sign-in → create project with mix → create campaign scoped to project → list returns campaign with project projection → all four nav routes render 200. | apps/api/prisma/schema.prisma, packages/contracts/src/{campaigns,index}.ts, apps/api/src/modules/campaigns/*, apps/api/src/app.ts, apps/api/src/test/{campaigns.test,setup}.ts, apps/web/src/lib/campaigns.ts, apps/web/src/app/(app)/campaigns/** | Claude |
| 2026-08-03 | feat | Password reset + email verification (closes two 🔴 GAPS.md rows). Three new pages — /forgot-password (request the reset), /reset-password?token=… (set a new password from the emailed link), /verify-email?token=… (confirmation surface for the verify link) — all in the landing theme with Reveal, the _kicker, and the acid-lime .mark. The login page's "reset coming soon" placeholder is now a real Forgot? link next to the password field. Backend flips emailVerification.sendOnSignUp: true so a fresh signup fires a verification email automatically without blocking login (unverified users can still use the app; the email flips users.email_verified on click, and per-endpoint gates can be layered later — e.g. billing requires verified). Reset & verify emails carry human-readable copy; the reset link expires in 1h, verify in 24h, both single-use. Config bug fixed in the same commit: config.betterAuth.baseUrl was using ??, which treated the empty-string default in .env.example as truthy — so sendOnSignUp's internal call (no HTTP request in scope for URL derivation) produced a relative /verify-email?… link that was unclickable. || coalesces empty strings too, so the fallback to PUBLIC_API_URL works. Two new behavioural tests cover the reset round-trip: token-in-identifier extraction, single-use, old-password rejection after reset, and no-user-enumeration on unknown emails. Full suite 158/158. Verified end-to-end against a running API + Mailpit: signup → verify email delivered → click → email_verified flipped false → true → 302 to /dashboard. | apps/api/src/core/better-auth.ts, apps/api/src/core/config.ts, apps/api/src/test/better-auth.test.ts, apps/web/src/app/(auth)/forgot-password/page.tsx, apps/web/src/app/(auth)/reset-password/{page,reset-form}.tsx, apps/web/src/app/(auth)/verify-email/{page,verify-form}.tsx, apps/web/src/app/(auth)/login/login-form.tsx | Claude |
| 2026-08-01 | feat | Frontend cutover to Better-Auth (slice 3). The browser app now talks to /api/v1/auth/v2/* for sign-in, session bootstrap and sign-out, and to the new /api/v1/auth/signup-with-org wrapper for signup — the wrapper preserves the "no user without an organization" invariant that Better-Auth's own signup doesn't know about, by creating the Org then delegating password hashing + user creation to auth.api.signUpEmail then linking the user to the org (with a compensating delete if the user creation fails, so an orphaned org never persists). Better-Auth's bearer() plugin enabled server-side so a Bearer header (unchanged from the legacy pattern) authenticates every /v2/* endpoint — no cross-origin cookie work needed in dev. http/middleware.ts requireAuth now accepts EITHER a Better-Auth session token OR a legacy JWT, resolving both to the same Prisma User row on the context — so every existing route handler works unchanged regardless of which auth flow issued the token. Frontend lib/api.ts drops the refresh-token dance (Better-Auth's DB-backed sessions have sliding expiry, no refresh primitive), a 401 clears state and bounces to /login. lib/auth.tsx adapts Better-Auth's camelCase user shape to the legacy snake_case User type at the boundary, so no downstream page/component changes. Legacy /api/v1/auth/* routes still mounted for the belt-and-braces window; retired in the cutover-cleanup slice. Social sign-in temporarily hidden — the legacy OAuth callback mints legacy JWTs that Better-Auth's session middleware doesn't recognise, and wiring OAuth providers to Better-Auth is a later slice. Full workspace gate green (156/156, both builds clean). | apps/api/src/core/better-auth.ts, apps/api/src/modules/auth-v2/routes.ts, apps/api/src/http/middleware.ts, apps/api/src/app.ts, apps/web/src/lib/api.ts, apps/web/src/lib/auth.tsx, apps/web/src/app/(auth)/social-buttons.tsx, apps/web/src/app/(auth)/callback/page.tsx | Claude |
| 2026-08-01 | feat | Better-Auth is real (slice 2 — schema + bcrypt bridge). Prisma schema gains Session, Account, Verification; User gains emailVerified + image and back-relations to the new tables. Better-Auth's advanced.database.generateId overridden to crypto.randomUUID() so its inserts land in our @db.Uuid id columns without a coercion error. Its default scrypt password hash swapped for bcrypt at cost 10 in both hash and verify — that's the bridge that lets an existing user's bcrypt hash (mirrored from users.hashed_password into accounts.password) authenticate through /v2/* without a reset. additionalFields on the User adapter (organizationId, role, isActive, isPlatformAdmin, tokenVersion) surface our tenancy/RBAC columns in the session payload. Backfill script at prisma/backfill-better-auth-accounts.ts (idempotent) mirrors every legacy hash into an accounts row. The seed creates both legacy + Better-Auth rows for the three seeded users so both paths log them in. 6 new behavioural tests over /v2/sign-up/email, /v2/sign-in/email (fresh user + seeded bcrypt bridge), /v2/get-session, and legacy coexistence — the full suite is now 156/156. Existing users unaffected; frontend still calls the legacy routes. Cutover is a later slice. | apps/api/prisma/schema.prisma, apps/api/src/core/better-auth.ts, apps/api/prisma/seed.ts, apps/api/prisma/backfill-better-auth-accounts.ts, apps/api/src/test/better-auth.test.ts, apps/api/src/test/setup.ts | Claude |
| 2026-07-31 | feat | Better-Auth wired in parallel (auth migration slice 1). New apps/api/src/core/better-auth.ts builds a Better-Auth instance on the Prisma adapter, exposes it as a lazy getBetterAuth() factory, and mounts it under /api/v1/auth/v2/* alongside the existing /api/v1/auth/*. The parallel prefix is the whole point — legacy routes keep serving users, Better-Auth exists but has no schema yet (its session/account/verification tables land in slice 2). docker-compose.yml gains a Mailpit service on :1025 (SMTP) and :8025 (browsable inbox) so every reset/verify email is inspectable in dev without a real provider account. .env.example gains BETTER_AUTH_SECRET (NO default — signing keys shouldn't have source-tree fallbacks), BETTER_AUTH_URL, MAIL_TRANSPORT_URL, MAIL_FROM. The mailer falls back to logging to stdout when no transport is configured so a dev environment without Mailpit still shows what would have been sent. Zero user-visible change; every existing endpoint behaves identically. Sequencing plan in docs/AUTH_VERJSON.md §6 (slice 1 of 6). | apps/api/src/core/better-auth.ts, apps/api/src/app.ts, apps/api/src/core/config.ts, docker-compose.yml, .env.example | Claude |
| 2026-07-30 | chore | Ported sources moved to archive/ and now tracked. Their nested .git directories were removed — a nested .git becomes a gitlink, a commit pointer with no submodule config, which clones as an empty directory nobody can check out. Each was verified clean first (no uncommitted changes, no unpushed commits, upstream remote present). Their node_modules stay untracked; one was 879MB. archive/ is excluded from the workspaces, both tsconfigs and lint, so nothing in it is built or shipped. | archive/**, .gitignore | Claude || 2026-07-29 | docs | Hootsuite gap analysis (COMPETITIVE.md) — most of the overlap was already in scope, because brightbean-studio was itself a Hootsuite-shaped platform. Nine features added (F-101 – F-109), and six deliberately refused with the reason recorded: chatbots, skill-based routing, 30+ network breadth, white-label, Canva embeds, advocacy gamification. Saying no is most of the value of the exercise. Also records what we have that Hootsuite does not — paid and organic in one budget model, an approval gate that covers AI, agency contractors as a first-class scope, and SEO/AEO alongside social. | docs/COMPETITIVE.md, data/dashboard/features.json | Claude || 2026-07-29 | feat | The UI wears the Verjson brand (D-024). Tokens, type and devices taken from verjson.com / verjson.ai: cream on ink, indigo for action, acid lime as a fill-only signature, Inter + JetBrains Mono, and the _section underscore kicker. Applied at the token layer, so /dashboard, /docs, /login and the app shell followed without touching them. | globals.css, layout.tsx, docs/DESIGN.md | Claude |
| 2026-07-29 | feat | The landing page has pictures, because the product does (D-025). Two new sections show features that are entirely about images: the media library (F-033) as a browsable bento grid with an asset inspector, and the ad asset pipeline (F-055) as one source file rendered at all five Google Ads ratios with a movable focal point — real CSS crops of one image, not a diagram of rectangles. Plus two full-bleed photographic bands to break the rhythm, and the channel registry rebuilt as a table instead of fifteen identical cards. 14 images, downloaded to public/img/ with provenance in CREDITS.json. | apps/web/src/app/(marketing)/**, apps/web/public/img/** | Claude |
| 2026-07-29 | fix | The board listed finished work as upcoming — labs.json keeps a milestone's ETA after the feature ships, so /dashboard and the landing roadmap both announced F-015 OAuth (done) as due 26 Aug. Fixed in the shared comingUp() helper, so both surfaces agree. | apps/web/src/lib/dashboard/status.ts | Claude |
| 2026-07-29 | fix | The approval simulator forced the page to scroll sideways at 390px: long monospaced actor identities (key:ak_live_8f2a · optimise·google-ads) set the grid item's min-content width, so truncate never engaged. min-w-0 on both panels. | apps/web/src/app/(marketing)/gate.tsx | Claude |
| 2026-07-29 | feat | SEO and AEO audits shipped (F-093 · F-094 · F-099) — crawl, pages and audit endpoints, plus 45 tests over the rules engine, extraction and crawl politeness. The audit runs over already-crawled pages rather than crawling on demand, so re-running it is instant and does not re-hammer someone's site — and that separation is what lets every rule be a pure function. | apps/api/src/modules/growth/** | Claude |
| 2026-07-29 | fix | The tests found a real gap: a canonical of arbitrary text ("not a url at all") resolves as a relative path rather than throwing, so new URL() never failed and the invalid-canonical rule never fired. It now shape-checks the raw value before resolving. | apps/api/src/modules/growth/rules.ts | Claude || 2026-07-29 | feat | The landing page is now operable, not just readable (F-089). Seven sections, each driven by data/dashboard/*.json: an interactive approval-gate simulator (pick a proposer, send it to the queue, approve or reject, watch the audit log fill in with both actors), a ⌘K palette over every feature, experiment, doc and route, a tabbed jobs explorer with a mock per job, filterable channels wired to each adapter's real feature status, the open-questions accordion from labs.json, a month-by-month roadmap, and a searchable explorer over all 86 features — including the 58 still planned, because a front door showing only finished work is a brochure. The one invented thing on the page, the KPI mock, is labelled a preview. | apps/web/src/app/(marketing)/** | Claude |
| 2026-07-29 | fix | Most of the landing page was invisible (B-009). Reveal interpolated its delay as seconds while every caller passed milliseconds, so delay={320} meant a 320-second fade-in. Nothing caught it because the elements were in the DOM — Playwright's toBeVisible() ignores opacity. The new guard asserts computed opacity. Also fixed sideways scroll at three widths (B-010). | apps/web/src/components/reveal.tsx, (marketing)/chrome.tsx | Claude |
| 2026-07-29 | fix | The roadmap advertised finished work as upcoming: labs.json keeps a milestone's original ETA after the feature ships, so F-015 OAuth (done) still read "26 Aug". Lab rows are now dropped when the feature they name is already done. | apps/web/src/app/(marketing)/data.ts | Claude |
| 2026-07-29 | test | 12 e2e cases for the landing page's interactive surfaces, including that the gate cannot be bypassed and that the page never scrolls sideways at five widths. E2E suite 5 → 17. | apps/web/e2e/landing.spec.ts | Claude |
| 2026-07-29 | feat | Developer docs at /docs (F-100) — 25 pages across 6 groups, navigation driven by data/docs/manifest.json. Each entry points at a markdown file, a generated view, or nothing yet; an unwritten page is listed on purpose and says what it will contain, because a nav showing only finished pages hides the shape of the thing being built. Nine new documents: getting started, data model, hierarchies, permissions matrix, glossary, environment, frontend routes, design guide, integrations, bug reports. | data/docs/manifest.json, apps/web/src/app/(docs)/**, docs/** | Claude |
| 2026-07-29 | feat | Growth intelligence foundation (F-093 · F-099) — a polite fetcher (robots.txt with longest-match precedence, per-host delay, honest UA with a contact URL, capped read), HTML extraction, and 18 audit rules as pure functions (11 SEO, 7 AEO) so the whole engine is testable with no network, no database and no fixtures. Scraping is a queue consumer, so it inherits retries and rate limiting rather than growing its own scheduler. | apps/api/src/modules/growth/** | Claude |
| 2026-07-29 | docs | OpenSEO adopted (D-023) — replaces the planned SEMrush integration for keywords, ranks, backlinks and competitors, and its ai-search feature answers LAB-008's open question about what the AEO metric actually is. Our on-page audit stays: it costs nothing per run and emits stable rule ids that map to fix PRs. | docs/INTEGRATIONS.md | Claude |
| 2026-07-29 | fix | Responsive pass. The app nav used overflow-x-auto, which "fit" at every width by slicing the last item in half (B-003). Replaced with three real layouts: drawer below md, icon-only md–lg, icon + label above. Scrollbars are now thin and token-coloured everywhere, and hidden inside .no-scrollbar containers — hidden, never disabled; every one still scrolls by wheel, drag, touch and keyboard. | apps/web/src/app/(app)/app-shell.tsx, globals.css | Claude |
| 2026-07-29 | fix | The API now connects to the broker at startup, so a healthy stack no longer reports queue: down until the first publish (B-004). And the E2E suite runs while the dev server is up, instead of colliding with it (B-001). | apps/api/src/main.ts, apps/web/playwright.config.ts | Claude || 2026-07-29 | feat | Agent runs and the approval gate (F-073 · F-074 · F-075 · F-097) — the surface humans and agents converge on. The gate is a state machine with the legal transitions in one dependency-free module: there is no edge from running to applied, and the test asserts that negative property exhaustively rather than by example, so an auto-apply mode added later fails the suite. transition() is the sole writer of status and uses a filtered updateMany, so two simultaneous approvals cannot both apply the same spend. Rejections require a reason. An agency contributor can see a run on their project but cannot sign off its spend. See D-022. | apps/api/src/modules/agents/**, apps/web/src/app/(app)/approvals/ | Claude |
| 2026-07-29 | feat | Durable execution with a fallback (F-097). Runs go to trigger.dev when configured and to our own RabbitMQ worker otherwise — deliberately, because a run that cannot start is a visible queued row, whereas a run that cannot be approved would be an outage of the control that stops money being spent. | apps/api/src/modules/agents/runner.ts | Claude |
| 2026-07-29 | fix | The agent-run scope filter used an { id: "__never__" } sentinel for "match nothing", which Postgres rejects against a @db.Uuid column — so the fail-closed branch 500'd instead of returning nothing. Branches are now composed rather than negated. | apps/api/src/modules/agents/domain.ts | Claude |
| 2026-07-29 | test | 18 new cases. API suite 87 → 105. | apps/api/src/test/agents.test.ts | Claude || 2026-07-29 | docs | Security findings register (SECURITY_FINDINGS.md) — all 19 findings from the review, what each one actually allowed, and what closed it. Rows are never deleted after closure: knowing what was once possible is what tells the next person which assumptions to re-check when they touch that code. | docs/SECURITY_FINDINGS.md | Claude |
| 2026-07-29 | feat | Refresh tokens are revocable (S-015). POST /auth/logout bumps a tokenVersion carried in the token, so a leaked token stops working instead of lasting its full 14 days — clearing localStorage only removed one browser's copy. Access tokens are deliberately not revoked: that would mean a DB read per request, which is the cost the stateless design exists to avoid; residual access is bounded by the 30-minute TTL. Tokens predating the field are treated as version 0, so no session broke. | apps/api/src/core/auth.ts, modules/auth/routes.ts, apps/web/src/lib/auth.tsx | Claude |
| 2026-07-29 | feat | ProjectMember.role is enforced (S-016). It was validated, stored and serialised while granting nothing — a viewer and a lead had identical power. Project update and budget writes now check it. Writing to a project you are not a member of returns 404, matching the read path so the write endpoint cannot be used to prove a project exists. | apps/api/src/modules/projects/domain.ts | Claude |
| 2026-07-29 | fix | Plan caps are atomic (S-017). The count moved inside the create transaction at Serializable isolation; a serialization conflict is retried up to three times so the loser re-reads the committed count and gets a 402 about the cap rather than a 500 about a transaction. | apps/api/src/modules/projects/db.ts | Claude |
| 2026-07-29 | fix | Retry queues split per delay tier (S-018). RabbitMQ only dead-letters from the head of a queue, so one queue with mixed per-message TTLs let a message parked for 80s block every 5s retry behind it. The TTL now lives on the queue and the tier is chosen by routing key. | apps/api/src/core/queue.ts | Claude |
| 2026-07-29 | refactor | Removed unused authorization helpers (S-019). requireRoles, assertPlatformAdmin, requirePlatformAdmin, assertPaidChannelsAllowed, decryptNullable were exported and never called — an exported authorization helper that nothing invokes reads as an enforced control. Each removal leaves a comment saying where the check actually lives. core/storage.ts and the unused serialisers are kept for queued features but now say plainly that nothing calls them yet. | apps/api/src/core/**, src/http/middleware.ts | Claude |
| 2026-07-29 | test | 7 new cases: token revocation (and that signing in again still works), viewer/contributor project roles, non-member 404, and three concurrent creates against a one-project cap. API suite 80 → 87. | apps/api/src/test/** | Claude || 2026-07-29 | breaking | OAuth state is now bound to the browser (D-018, supersedes D-012). Signing alone did not prevent session fixation, and the old rationale said it did — /start is unauthenticated, so an attacker could mint a genuinely-signed state, complete consent with their own account, and hand a victim a callback URL that logged the victim into the attacker's organization. Via /link-url it was worse: a victim's consent could attach their GitHub identity and repo-scoped token to the attacker's account. /start now sets an HttpOnly, SameSite=Lax, __Host- cookie; the state carries only the nonce hash; the callback requires both, compares in constant time, and clears the cookie before doing any work. An explicit link against an already-bound provider account is a 409. | apps/api/src/modules/oauth/** | Claude |
| 2026-07-29 | breaking | The project scope filter is now an allow-list (D-019). It read "if agency or client, restrict to memberships", which fails open — any unrecognised role got org-wide read of every project, its budgets and its team. User.role is now a Prisma enum as well, so the column cannot hold a value no check anticipates. | apps/api/src/modules/projects/db.ts, prisma/schema.prisma | Claude |
| 2026-07-29 | fix | Plan is derived from the Stripe price, not metadata (D-020). Metadata is written once at checkout and never updated by a portal plan change, so an org downgrading scale → starter kept scale limits indefinitely; and ?? "starter" silently granted a paid plan to any subscription without metadata. Unknown price now degrades to free and logs loudly. | apps/api/src/modules/billing/domain.ts | Claude |
| 2026-07-29 | fix | Webhook idempotency claimed by INSERT before the work (D-021), so concurrent deliveries cannot both apply and the loser cannot 500 into a Stripe retry storm. Also found writing the tests: an over-broad catch was reporting "billing not configured" as "Invalid signature" — verification now uses a client built for that one job. | apps/api/src/modules/billing/domain.ts | Claude |
| 2026-07-29 | fix | Rate limiting was bypassable — the first X-Forwarded-For hop is caller-controlled, so any client could send a random value per request and never trip the login limiter. Now uses the Nth hop from the right per TRUSTED_PROXY_COUNT, and ignores the header entirely when no proxies are configured. Added limits to /auth/refresh, /billing/checkout, /billing/portal and the OAuth link endpoints. | apps/api/src/http/middleware.ts | Claude |
| 2026-07-29 | fix | Validation gaps that surfaced as 500s or a crashed page: 2026-13-45 passed the date regex and became an Invalid Date at Prisma; 1$# passed length(3) and threw RangeError inside Intl.NumberFormat, taking down the projects page for the whole org; channels was unbounded and un-deduplicated; website_url accepted javascript:. | packages/contracts/src/projects.ts | Claude |
| 2026-07-29 | fix | The unauthenticated health probe dialled AMQP per request with no in-flight dedupe, leaking a connection per concurrent probe. It now reads cached state and never dials; connect() memoises the in-flight promise. | apps/api/src/core/queue.ts, modules/health/routes.ts | Claude |
| 2026-07-29 | fix | Audit trail gained ipAddress (the column existed and was always null) plus entries for login success, login failure, and OAuth unlink — a burst against one account is invisible if only successes are recorded. Provider error bodies are no longer logged: they routinely echo the client secret. | apps/api/src/core/audit.ts, modules/auth/routes.ts, modules/oauth/** | Claude |
| 2026-07-29 | test | 17 new cases covering every fix above, including the three the review named as missing. API suite 63 → 80, all green — and the queue's two integration cases now run against a real broker rather than skipping. | apps/api/src/test/** | Claude || 2026-07-29 | feat | Work queue and worker process (F-096) — direct exchange, a durable queue per job kind, a broker-side delayed retry queue and a dead-letter queue. Retries live in the broker rather than an in-process sleep, so a redeploy mid-backoff does not lose the job; failures re-publish with an incremented attempt instead of nack(requeue: true), which would hot-loop. prefetch is set per kind because nearly every consumer talks to a rate-limited API. Worker is the same image as the API with a different command; WORKER_KINDS lets a slow kind get its own Deployment. ⚠️ The broker round-trip is not yet verified — see VERIFICATION.md. | apps/api/src/core/queue.ts, src/worker.ts, src/jobs/index.ts | Claude |
| 2026-07-29 | feat | Health probe now reports broker reachability. A down broker is reported but does not fail readiness — taking the service out of rotation because a queue is unavailable turns a degraded feature into a full outage. | apps/api/src/modules/health/routes.ts | Claude || 2026-07-29 | breaking | RabbitMQ replaces Postgres as the work queue (D-014). Almost every consumer talks to a rate-limited third-party API, and per-queue prefetch, fair dispatch and native dead-lettering are exactly those needs — a polling table reimplements all three. The rate limiter stays on Postgres; a fixed-window counter is not a queue. Compose gains rabbitmq and a worker service running the same image as the API. Supersedes the queue half of D-005. | docker-compose.yml, docs/INFRASTRUCTURE.md | Claude |
| 2026-07-29 | feat | Baileys WhatsApp bridge as its own container (F-048, D-015) — persistent WhatsApp Web session on its own volume, behind the shared ChannelProvider interface. Flagged plainly in the docs: it is an unofficial client, bulk marketing over it breaches WhatsApp's terms, and the downside is a banned number. Kept behind the interface so the official Cloud API is a transport swap rather than a rewrite. | docker-compose.yml, docs/INFRASTRUCTURE.md | Claude |
| 2026-07-29 | docs | Infrastructure recorded (D-014 · D-015 · D-016 · D-017) — new INFRASTRUCTURE.md covering why RabbitMQ and trigger.dev both exist (short jobs vs long durable runs, and what breaks if either does the other's job), the queue topology, the Baileys trade-off, and scraping rules. Added F-096 queue, F-097 trigger.dev workflows, F-098 Agent-Reach research layer, F-099 scraper framework. | docs/INFRASTRUCTURE.md, docs/DECISIONLOG.md, data/dashboard/** | Claude || 2026-07-29 | feat | OAuth sign-in with Google and GitHub (F-015) — one provider registry entry per provider, so adding a third changes nothing else. State is a signed short-lived JWT (the callback is a cross-site GET, so unsigned state is a session-fixation hole); accounts link only on a provider-verified email; the redirect target is allow-listed to our own origin; tokens land in the URL fragment so they never reach a server log or a Referer header. See D-012. | apps/api/src/modules/oauth/**, apps/web/src/app/(auth)/** | Claude |
| 2026-07-29 | feat | Encryption at rest (F-008, partial) — AES-256-GCM with per-purpose keys derived by HKDF, so the OAuth-token key and the credential-vault key are different keys from one configured secret. Plus one-way token hashing and constant-time comparison. ENCRYPTION_KEY has no default on purpose: a fallback would mean production secrets encrypted with a key from the source tree. | apps/api/src/core/crypto.ts | Claude |
| 2026-07-29 | fix | The API build produced an unrunnable bundle. tsc does not rewrite @/* path aliases on emit, so dist contained specifiers Node cannot resolve. Switched to tsup. See D-013. | apps/api/tsup.config.ts | Claude |
| 2026-07-29 | fix | next build failed at prerender because /login reads ?error= via useSearchParams. Split into a Suspense boundary + form. | apps/web/src/app/(auth)/login/** | Claude |
| 2026-07-29 | test | 20 new cases — OAuth state forgery/expiry/wrong-purpose, the redirect allow-list, unconfigured vs unknown providers, and encryption properties (tamper detection, key separation). E2E suite rewritten for the current routes: 5/5. API suite 30 → 50. | apps/api/src/test/{oauth,crypto}.test.ts, apps/web/e2e/smoke.spec.ts | Claude || 2026-07-29 | feat | Projects, marketing mix and budgets (F-020 · F-025 · F-026) — the first vertical slice. A company creates a project, declares which of twelve marketing channels it invests in, and sets a budget per channel per period. Money is integer minor units end to end; the roll-up is computed server-side and excludes foreign-currency budgets rather than adding them together. Re-setting a channel and period replaces the amount rather than stacking rows. | apps/api/src/modules/projects/**, packages/contracts/src/projects.ts, apps/web/src/app/(app)/projects/** | Claude |
| 2026-07-29 | feat | Agency project-scoping (F-005 · F-006) — agency and client roles added, and project membership made the scope: without a ProjectMember row an outside contractor sees nothing, even inside their own organization. Enforced as a where clause in the data-access layer, so out-of-scope reads 404 rather than 403 and ids cannot be probed. See D-009. | apps/api/src/modules/projects/db.ts, domain.ts | Claude |
| 2026-07-29 | feat | Login, signup and the app shell — (auth) route group and a session-guarded (app) group. The build board moved to (board) so it stays outside the guard. | apps/web/src/app/(auth)/**, apps/web/src/app/(app)/app-shell.tsx | Claude |
| 2026-07-29 | test | Projects suite: 22 cases covering tenant isolation (404 not 403), agency scoping, role permissions, cross-org invite rejection, budget upsert semantics, channel validation and currency isolation. API suite now 30/30. | apps/api/src/test/projects.test.ts | Claude |
| 2026-07-29 | docs | Scope expanded per the second brief: OAuth (F-015), Stripe (F-016), live campaign control (F-059), WhatsApp (F-048), and a new Growth intelligence group — SEMrush (F-091), Apollo (F-092), SEO audit (F-093), AEO readiness (F-094), GitHub PRs (F-095). Board now 81 features. Added D-009, D-010, D-011 and LAB-007/008/009. | docs/**, data/dashboard/** | Claude |
| 2026-07-29 | chore | Branching model: prod ← dev ← feat/F-###-*, one branch per feature id, npm run verify before every merge. | docs/BRANCHING.md | Claude || 2026-07-29 | feat | Project dashboard — /dashboard, a JSON-backed command centre with seven tabs: Overview (with a computed "what's coming up"), Features checklist, User flows, Architecture, Tests, Labs, and Docs (renders docs/*.md in-app). Data lives in data/dashboard/*.json. | apps/web/src/app/(board)/dashboard/**, data/dashboard/** | Claude |
| 2026-07-29 | feat | Landing page — the internal front door: what the studio is, the four pillars, the channel surface, the agentic layer, and live build status read from the dashboard data. | apps/web/src/app/(marketing)/page.tsx | Claude |
| 2026-07-29 | docs | Full docs set rewritten for this product — brief, architecture, features (69 rows across 8 groups), API contract, decision log (8 ADRs), testing strategy, verification, deployment, and a porting map recording what comes from brightbean-studio vs adwords-adsense and what was deliberately dropped. | docs/** | Claude |
| 2026-07-29 | breaking | Split into two deployables. The single Next.js app became an npm-workspaces monorepo: @verjson/api (Hono, owns Prisma and every database credential), @verjson/web (Next.js, no DB driver), @verjson/contracts (zod schemas both import). The seam is enforced by no-restricted-imports in both apps, not by convention. Supersedes the template's D-001. | apps/**, packages/**, docker-compose.yml | Claude |
| 2026-07-29 | refactor | API rewritten from Next route handlers to Hono. src/server/* → apps/api/src/core/* (framework-agnostic) plus src/http/* (transport). Auth, health, audit, rate limiting, storage and serializers ported with behaviour preserved; the test harness now drives the real app in-process via app.request() instead of calling route functions directly. | apps/api/src/** | Claude |
| 2026-07-29 | test | Auth suite extended from 5 to 8 cases — added duplicate-email 409, refresh-token exchange, and a 422 contract-violation case. | apps/api/src/test/auth.test.ts | Claude |
| 2026-07-29 | chore | Dockerfiles for both apps (multi-stage, unprivileged, health-checked) and a Compose apps profile that runs the production topology locally. | apps/*/Dockerfile, docker-compose.yml | Claude |
| 2026-07-29 | chore | Renamed from app-starter to verjson-marketing-studio; token storage keys, S3 bucket, container and volume names all rescoped. | package.json, .env.example | Claude |
Types: feat · fix · refactor · docs · chore · perf · test · breaking.