Markdown-checkbox tracker. Updated after every implementation step. [x] = done, [ ] = pending. Blockers and tech debt at the bottom.
- Create monorepo directory tree (
backend/,frontend/, subsystem folders, tests) - Seed
CLAUDE.md(rules + guardrails) - Seed
CONTEXT.md(architecture memory) - Seed
CHECKLIST.md(this file) - Seed
INSTRUCTIONS.md(operational runbook) - Create root
.gitignore - Create root
.env.example - Create root
README.md - Backend: create
venv(Python 3.12),requirements.txt - Backend: FastAPI hello-world (
app/main.pywith/health+/) - Backend:
app/config.py(pydantic-settings) - Backend:
app/core/logging.py(structlog) - Backend:
app/db.py(SQLAlchemy async engine + session +Base) - Backend: Alembic init +
alembic.ini+ asyncenv.py - Frontend: Next.js 16 + React 19 + Tailwind 4 + TypeScript + App Router (scaffolded into
frontend/) - Frontend:
.env.local.example - Verify backend boots via
uvicorn app.main:app(200 on/health) - Verify frontend boots via
npm run dev(200 on/) -
git initand initial commit (when user requests)
- Generate
APP_SECRET_KEYviapython -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" - Provision Supabase project → copy Session Pooler URL (port 5432) + anon/service-role keys
- Get Groq API key from console.groq.com (no card)
- Get LinkedIn App #1 credentials (CLIENT_ID + CLIENT_SECRET) — already set up
- Create Reddit script-type app → get CLIENT_ID + CLIENT_SECRET
- Fill
backend/.envwith all of the above; fillfrontend/.env.localwith theNEXT_PUBLIC_*values
- Alembic migration 0001: all 10 tables (users, linkedin_accounts, trends, prompts, templates, posts, generated_assets, ai_generations, publishing_logs, analytics)
-
linkedin_accountsschema includesaccount_kindenum ('personal','organization'),member_sub, nullableorg_urn,scopestext[] - SQLAlchemy ORM models (one file per table under
backend/app/models/) -
alembic upgrade headruns cleanly against Supabase Session Pooler URL - Verified: 11 tables + alembic_version + 7 enum types live in Supabase
publicschema
- Frontend: Supabase Auth client (
lib/supabase.ts) +AuthProvidercontext + magic-link login page (/login) - Backend: Supabase JWT verification via JWKS (ES256/RS256/EdDSA) —
app/core/auth.py::get_current_user. Optional HS256 fallback viaSUPABASE_JWT_SECRETfor legacy projects. - JIT user provisioning (first JWT verification creates the
usersrow)
- Backend:
app/core/security.py— Fernetencrypt/decrypt+ TTL-aware variant for OAuth state - Backend:
app/publishing/oauth.py— encode/decode state (Fernet + 10-min TTL), build authorize URL, code exchange, refresh, OIDC userinfo - Backend:
app/api/auth_linkedin.pywith/auth/linkedin/start,/auth/linkedin/callback,/auth/linkedin/status - On callback: fetch OIDC
userinfo, storesubaslinkedin_accounts.member_sub,account_kind='personal', encrypted access/refresh tokens, scopes array - API router mounted at
/api/v1— OpenAPI shows/start,/callback,/status - Frontend: "Connect LinkedIn" button on dashboard with status display + reconnect
- Signed in via Supabase magic link → landed on dashboard
- Clicked "Connect LinkedIn" → completed OAuth → callback succeeded →
linkedin_accountsrow created withaccount_kind='personal'andexpires_atset (verified end-to-end by user)
-
app/retrieval/base.py—FetcherABC +TrendItemdataclass -
app/retrieval/rss.py— 6 locked feeds (Stratechery, First Round, Stripe, Pragmatic Engineer, OpenAI, Hugging Face),httpx+feedparser, polite UA, in-memory ETag / Last-Modified cache, parallel viaasyncio.gather -
app/retrieval/hackernews.py— top 30 + best 20 viahacker-news.firebaseio.com/v0, skips dead/deleted/non-story items, falls back to HN item URL when externalurlmissing -
app/retrieval/reddit.py— written but disabled in orchestrator until Reddit Data API approval (PRAW inasyncio.to_thread, 8 starter subreddits) -
app/retrieval/dedupe.py— URL normalization (strips utm_*, fbclid, gclid, ref, trailing slash, lowercases host) + trigram-Jaccard title similarity at threshold 0.85 - Smoke-verified: all 5 modules import cleanly, dedupe collapses URL+title duplicates correctly
-
app/retrieval/orchestrator.py— calls enabled fetchers in priority order (RSS → HN), dedupes, bulk-inserts viaINSERT ... ON CONFLICT DO NOTHING ... RETURNING id -
app/scheduler/scheduler.py+app/scheduler/jobs.py— APScheduler AsyncIOScheduler withSQLAlchemyJobStore(sync URL coerced),retrieval_job()registered on cronRETRIEVAL_CRON - Scheduler started in FastAPI
lifespanhook inapp/main.py(startup log lists registered job IDs) -
POST /api/v1/trends/refreshendpoint (manual trigger, auth required) -
GET /api/v1/trends?status={unconsumed|consumed|all}&source=...&limit=Nendpoint - RSS: First Round Review dropped (RSS discontinued, both URLs 404); per-feed cap of 25 + 90-day age cutoff added to keep unconsumed pool fresh
- End-to-end verified: orchestrator pulls 180 items / run from real feeds (7 RSS + HN) into Supabase; backend boots with scheduler started; both endpoints return 403 unauthenticated
- RSS feeds expanded to 7: added Simon Willison (independent AI commentary, multi/day) + Latent Space (AI engineering, 2-3×/week)
- Cleared 1,837-row pre-cap flood via
TRUNCATE TABLE trends— fresh pool for Phase 3
- Backend:
PATCH /api/v1/trends/{id}(consume toggle, body{consumed: bool}) - Frontend:
apiPatchhelper inlib/api.ts - Frontend:
/trendspage — status + source filters, refresh button with summary banner, per-row consume toggle with optimistic UI, source-coded badges - Frontend: dashboard "Trends" card linking to
/trends - End-to-end verified by user in browser: refresh → trends populate → filter / consume / mark-unread all work
- Tests: 27 passing in <0.4s —
test_dedupe.py(URL normalization, title similarity, dedupe),test_rss.py(happy-path, age cutoff, per-feed cap, 304, isolated failures),test_hackernews.py(top+best dedup, skip dead/deleted/non-story, fallback URL, score capture) -
backend/pyproject.tomladded for pytest config (asyncio auto-mode)
Locked design decisions (carry over post-compact):
- Article body fetched on-demand at generation time, not pre-fetched at retrieval.
trafilaturaextracts clean text from each picked URL; LLM getstitle + summary + clean article bodyper trend. Graceful fallback to title+summary if fetch fails (paywall, 404). - Voice direction: contrarian + builder-grounded. Anchored via 2-3 example posts inside each prompt template (LLM follows examples > rules). Signature pattern per post: contrarian/confessional hook → one specific concrete detail → punchy takeaway. No survey-of-perspectives, no closing summary. Prompts live in the
promptstable so voice tuning needs no code deploys. - Prerequisites confirmed:
GROQ_API_KEYpresent inbackend/.env.
-
trafilatura==2.0.0+groq==1.4.0(bumped from 0.15.0) pinned inrequirements.txt(web-verified per CLAUDE.md rule) -
app/intelligence/groq_client.py—chat(stage, messages, ...)entry point;AsyncGroq+tenacityAsyncRetrying(4 attempts, exp backoff 1–10s) retrying onlyAPIConnectionError/RateLimitError; every call writes anai_generationsaudit row (input, output, tokens, latency, stage) via_log_generationwhose own failures are logged not raised -
app/intelligence/article.py—fetch_article(url)via httpx (10s timeout, polite UA, follow redirects) +trafilatura.extractinasyncio.to_thread(favor_precision, no comments/tables); 8K char cap; any failure (network / non-200 / empty extraction) returnsNone - Tests: 12 passing in <2s —
test_groq_client.py(happy path, default model, response_format pass-through + omission, non-retryable propagates + still audits, retryable then success counts attempts + one audit row, missing API key raises);test_article.py(extracts + strips boilerplate, non-200 → None, network error → None, unextractable HTML → None, truncation to MAX_CHARS)
-
app/intelligence/voice.py—strip_ai_tells(text) → (cleaned, violations)(em-dash separator → period, closing-summary paragraph drop, 10 banned phrases stripped case-insensitively, whitespace tidying — idempotent).polish_voice(text)optional second LLM pass onGROQ_MODEL_FAST(constrained rewrite) — called explicitly, never auto-triggered. - Alembic migration
c7a4f1e8b2d3_seed_prompts.py— 4 prompts seeded intopromptstable, all v1 +is_active=true. Each ~2K-char body: voice rules, 3 anchor example posts (contrarian/confessional hook → concrete specific → punchy takeaway), and category-specific framing. Applied to Supabase; all 4 visible.founder_insight,engineering_lesson,ai_automation,startup_opinion
-
app/intelligence/generator.py— 3-stage pipeline split intorun_pipeline()(pure LLM, testable in isolation) +generate_draft()(DB orchestration). Stage 1 angle (primary, T=0.85) → Stage 2 voice rewrite (primary, T=0.7) →strip_ai_tells→ Stage 3 extract (FAST, T=0,response_format=json_object) →_parse_extractwith graceful fallback on bad JSON.generate_draftloads active prompt, picks ≤3 trends (caller-supplied IDs or unconsumed by score+freshness), fetches article bodies in parallel, persistsPost(status=draft), marks source trendsconsumed_at. -
POST /api/v1/posts/generate(body:{category, trend_ids?}) → returns draft;GET /api/v1/posts?status=&limit=(newest-first);GET /api/v1/posts/{post_id}. Router mounted at/api/v1/postsinapp/api/__init__.py. All auth-gated (verified 403 unauthenticated). - Tests: 27 new tests in
tests/intelligence/covering voice (14: em-dash separator, closing summary, 10 banned phrases case-insensitively, whitespace tidying, idempotence) + generator (13:_parse_extracthappy/bad-JSON/hash-strip/cap-3/dedupe/non-list/missing-hook,_format_trendincludes/omits/truncates, full 3-stage flow asserts stage order + models + prompt_id propagation + JSON format, voice strip runs between stages 2 and 3, bad JSON in stage 3 falls back). 66 total passing in <2s.
-
frontend/app/drafts/page.tsx— list view: status filter dropdown (draft / approved / scheduled / published / failed / all), inline "Generate a draft" card with category dropdown, category + status colored badges per row, hook preview, hashtag row, click-through to detail. Hot-fetches after a generation completes. -
frontend/app/drafts/[id]/page.tsx— read-only detail view (category + status badges, hook section, full body in pre-wrap, tags row, source-trend-count + post ID metadata, "Generate another" CTA that routes to the new draft). Uses Next.js 16params: Promise<{id}>+React.use()per the in-repo Next.js docs (the AGENTS.md flag was real —paramsis no longer sync). - Dashboard nav: replaced "Coming soon" placeholder with a Drafts card linking to
/drafts. - Browser e2e verified by user: generate (each of the 4 active categories) → draft appears → click → detail view → "Generate another" routes correctly.
The first-pass v1 prompts produced posts that read as obvious AI output. Three migration rounds tuned the voice without code changes (the versioned prompts table is doing its job — voice tuning is a DB write, not a deploy):
- v1 → v2 (
f3b8a72c9d41_prompts_v2_reaction_style.py): v1's anchor examples were first-person achievement stories ("I shipped a 'better' onboarding for six weeks") which the model interpreted as license to fabricate autobiography from whatever the trend said ("I made a million dollar product from my dorm room"lifted verbatim from a HN post about nice!nanos keyboards). v2 replaces anchors with reaction-style examples ("Saw a thread claiming X. Disagree because…") and adds a CRITICAL CONTEXT block: "the trends are things you READ today, you did NOT do them." - v2 → v3 (
a5d2b9e4f637_prompts_v3_length_sharpness.py): v2 outputs collapsed to ~40-55 word tweet-fragments ("Simple is better." / "Cheaper and more reliable.") because the voice rewrite system prompt said "max 12 words per line" and the model interpreted that as max 12 words per sentence. v3 expands the line-length rule to "140-220 words, 4-7 paragraphs, full sentences," and adds a sharp-takeaway rule that explicitly rejects generic affirmations ("AI is real", "simple is better", "humans matter"). - v3 → v4 (
b4e7c1f293a8_prompts_v4_real_anchors.py): v3 fixed length but drifted into tech-blog explainer mode. v4 replaces fabricated anchors with examples built around REAL positions held by working founders — Jason Fried on hiring-by-evidence-not-tenure, DHH on small-team microservices as org-theatre, Sahil Lavingia on VC-only-when-VC-is-the-only-fit, Simon Willison on coding-agents-PMF (with his real $2,180/mo + Uber's 25% via Claude Code numbers). Also adds 12 more banned phrases tovoice.pycatching what slipped past v3 ("is also worth noting", "sparked my interest", "I've seen many teams", "one notable aspect", etc.) and a "REACT, DON'T EXPLAIN" rule with an explicit "<25% describes the trend, >75% is YOUR opinion" budget.
After v4, outputs swung the other way — 280-430 words of explainer text. Two infra-level (not prompt-level) caps fix this without another prompt iteration:
generator.py:VOICE_MAX_TOKENS800 → 350 (hard ceiling on body),ANGLE_MAX_TOKENS800 → 500.article.py:MAX_CHARS8000 → 2500. Less raw article material for the model to recapitulate.
Voice tuning has hit diminishing returns on prompt iteration without real positive/negative training samples. Deferring further voice work to Phase 8 (Hardening), where we'll have actual generation history to mine for "this was good" / "this was bad" anchors and bump to v5 with sample-driven anchors. Categories, schema, generator pipeline, audit log, and UI are all production-shaped.
-
app/rendering/playwright_runner.py— singleton headless Chromium booted in FastAPIlifespanhook. One process-wide browser; throwaway context per render for total isolation.html_to_png(html, width, height, device_scale_factor=2)is the only public render primitive. - FastAPI lifespan now boots/shuts the runner after the scheduler (
app/main.py::lifespan). -
BRAND_NAME=Nestwhirradded toapp/config.py+backend/.env.example(used as the footer-strap brand on every template). -
playwright install chromiumrun locally (Phase 0 only pinned the package; the binary was missing). Confirmed~/Library/Caches/ms-playwright/chromium_headless_shell-1148. - Smoke-verified: minimal
insight_card.htmlrendered to a 2400×2400 PNG via the full lifespan loop.
- Four HTML/Tailwind templates committed at
app/rendering/templates/:insight_card.html— eyebrow + centered serif headline + accent line + brand strapquote_card.html— italic serif + oversized quotation mark anchor (dramatic)metric_card.html— top-aligned uppercase bold sans display type (data-card feel)carousel_slide.html— slide-indicator + serif headline + swipe-affordance when not last slide
- All four share one minimal-dark identity (black bg, serif headline, sans body, accent line above brand strap). Tailwind via CDN inside each HTML — no build step.
-
app/rendering/renderer.pytwo-layer split:- Pure:
render_html()+render_png()+default_template_name_for_post()+default_context_for_post(). Testable in isolation. - DB orchestration:
render_post(session, post_id, template_id?)— loads Post, picks Template (explicit id or category default), renders bytes, writes file atbackend/storage/assets/{post_id}/{asset_id}.png, persistsGeneratedAssetrow.
- Pure:
- Category → default template mapping in renderer: founder_insight → insight_card, engineering_lesson → quote_card, ai_automation → metric_card, startup_opinion → quote_card, technical_humor → insight_card, carousel → carousel_slide.
- Alembic data migration
d8e2f4a91c56_seed_templates.py— 4 templates seeded intotemplatestable viaop.bulk_insert. Applied to Supabase; all 4 rows visible. - Endpoints added to
app/api/posts.py:POST /api/v1/posts/{post_id}/render(body{template_id?}) → returnsGeneratedAssetRead(id, post_id, template_id, image_path, width, height, linkedin_asset_urn, created_at, image_url).GET /api/v1/posts/{post_id}/assets→ newest-first list.
-
app/api/templates.py—GET /api/v1/templatesfor the future template-picker UI. - FastAPI
StaticFilesmount at/static/assets→backend/storage/assets/. Dev-only (backend binds to 127.0.0.1 per CLAUDE.md).GeneratedAssetRead.image_urlis computed asf"{APP_BASE_URL}/static/assets/{image_path}"so the frontend can fetch directly. - Tests: 17 new in
tests/rendering/test_renderer.py(default-template mapping, default context, Jinja2 escaping, parametrized render-each-template,render_pngcalls playwright with right HTML + dims,_pick_templatehappy/explicit/missing-id/missing-default). Full suite 87 passing in 1.78s. No real Chromium, no real DB —playwright_runner.html_to_pngmonkeypatched,_pick_templateexercised againstunittest.mock.AsyncMock. - End-to-end DB-side smoke verified: latest draft →
render_post()→ 153KB PNG written tobackend/storage/assets/{post_id}/{asset_id}.png+ GeneratedAsset row inserted +template=insight_cardauto-picked fromcategory=founder_insight.
- Slides 2..N were being buried inside the collapsed
"earlier renders"<details>after a carousel render — bad UX for the actual content the user just generated. Split the two concepts:- Carousel slides (new, always visible) — shows every slide of the most recent carousel batch as thumbnails with
1/N,2/N, ... labels. Click any → full size in a new tab. - Earlier renders (existing, collapsed) — keeps only what it was meant for: prior render attempts before the current carousel.
- Carousel slides (new, always visible) — shows every slide of the most recent carousel batch as thumbnails with
- Frontend detects the "current carousel batch" via
useMemooverassets: takes the front-of-list cluster ofcarousel_slideassets whosecreated_attimestamps are within 60s of each other. A 60s window is generous (real carousel batches take ~1-2s per slide); user re-rendering 60s+ later starts a new batch and the previous one rolls into "earlier renders". A single carousel slide (length === 1) doesn't show the strip — same shape as a single-image render. -
olderslice now starts atcurrentCarousel.length || 1so slides 2..N never appear in both sections. - TypeScript clean. Page compiles + returns 200.
-
app/rendering/renderer.py::render_post_carousel(session, *, post_id, template_id?, ...)— loops through every slide inpost.extracts.carousel_slides, renders + uploads each, persists NGeneratedAssetrows in a single commit. Same lazy-Stage-4 backfill semantics asrender_post(). Validates the picked template is actuallycarousel_slide(errors otherwise). -
POST /api/v1/posts/{id}/render-carousel→ returnslist[GeneratedAssetRead](one entry per slide, slide-1-first order). Auth-gated like every other route. Errors translateValueError→ 400. - Frontend
/drafts/[id]/page.tsxdispatches to the right endpoint based on the selected template:- Explicit
carousel_slidepick OR Auto-pick on acarousel-category post →/render-carousel(N assets in one click) - Anything else →
/render(one asset) - Button label adapts:
"Render carousel (5 slides)"/"Rendering 5 slides…"for the carousel path, plain"Render"/"Rendering…"for single-image.
- Explicit
-
Posttype in the frontend gainsextracts: PostExtracts | nullso the button can show the live slide count frompost.extracts?.carousel_slides?.length. - TypeScript clean (
npx tsc --noEmit). - Full test suite still 110 passing in 1.78s (no test changes needed — pure-Jinja2 + context tests already cover the surface).
- End-to-end smoke verified: rendered the 5-slide
ai_automationpost viarender_post_carouseldirectly. 5 separate Supabase Storage POSTs all returned 200; row count delta = 5; one single DB commit at the end (no partial state on a mid-loop failure). Total wall-clock ≈ 6 s for 5 slides. - Browser e2e — user-driven check: open a carousel post → pick
carousel_slidefrom the dropdown (or use Auto on acarousel-category post) → click Render carousel (N slides) → wait ~6 s → N images persist; latest preview shows slide 1, others tuck into the "earlier renders" thumbnail strip.
-
supabase==2.13.0added torequirements.txtand installed; deps includestorage3==0.11.3(the underlying storage client). -
SUPABASE_STORAGE_BUCKET=autopost-assetsadded toapp/config.py+backend/.env.example. User created theautopost-assetspublic bucket in the Supabase dashboard. Public so LinkedIn's media-upload API can fetch the URL at publish time without signed-URL gymnastics. -
app/rendering/storage.py— Supabase Storage wrapper: lazy module-levelClient(created fromSUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY),upload_png(path, png_bytes) -> str(PNG upload, wraps sync supabase-py inasyncio.to_thread),public_url(path) -> str(constructs the CDN URL without a network call). -
app/rendering/renderer.pyupdated: removedASSETS_DIRconstant and theabs_path.write_bytes(...)block;render_post()now callsawait storage.upload_png(rel_path, png_bytes)after rendering.image_pathshape unchanged ({post_id}/{asset_id}.png) — bucket prefix lives in the URL, not the column. -
app/api/posts.py::GeneratedAssetRead.image_urlswitched tostorage.public_url(self.image_path)— pattern is{SUPABASE_URL}/storage/v1/object/public/{bucket}/{path}. Frontend<img>tag picks it up unchanged. -
app/main.py— dropped thefrom fastapi.staticfiles import StaticFilesimport, thefrom app.rendering.renderer import ASSETS_DIRimport, theASSETS_DIR.mkdir(...)call, and theapp.mount("/static/assets", ...)block. Backend no longer serves images. - End-to-end verified live: rendered the latest post via the new pipeline; Supabase Storage
POST /storage/v1/object/autopost-assets/...returned 200; public URLHEADreturned 200 with matchingContent-Length: 145777andContent-Type: image/png. CDN is serving. - Cleanup: deleted 17 stale
GeneratedAssetrows pointing at the old local paths; removed the 2.2 MBbackend/storage/assets/directory. Posts themselves untouched —Posttable is the same; the rendering-history scratch was the only collateral. - Full test suite passes: 110 passing in 1.80s. Existing renderer tests didn't need changes because
playwright_runner.html_to_pngwas already monkeypatched andrender_post()was already not unit-tested (its DB orchestration is covered by manual end-to-end smoke). The pure-Jinja2 + context-shape tests cover the unchanged surface.
- Alembic migration
e9f3c5b81d72_add_extracts_to_posts.py— adds nullableposts.extractsJSONB column; existing rows stay NULL (lazy-fill on first re-render). -
Post.extracts: Mapped[dict|None]added to ORM model;PostReadschema exposes it through the API. - Stage 4 added to generator (
extract_visual_fields()is the public surface;_VISUAL_EXTRACT_SYSTEMis the FAST-model prompt;_parse_visual_extract()tolerantly parses bad JSON / non-dict / missing fields / bare-string slides / orphaned metric_value-without-label). - Stage 4 wired into
run_pipeline()→ returns 5-tuple(hook, body, tags, extracts, violations).generate_draft()persistsextractson the new Post. Verified live: ~700ms / ~130 tokens per Stage 4 call, ~$0.0001 per draft on the FAST model. - Renderer reads from
post.extractswith hook fallback:default_context_for_post(post, slide_index=1)now exposespull_quote / metric_value / metric_label / slide_text / slide_number / slide_total. Missing extract fields fall back tohook.render_post()acceptsslide_index; clamps out-of-range values; lazy-extracts (calls Stage 4 inline + persists) on the first render of any pre-Chunk-D draft.default_template_name_for_post()got a smart fallback: ai_automation posts without a real metric auto-pickinsight_cardinstead ofmetric_cardso long hooks don't overflow the canvas.
- Three templates updated to consume extracts:
quote_card.html—{{ pull_quote|default(hook) }}metric_card.html—{% if metric_value %}shows value at 160px + label, else fallback uses 64px sans bold with|truncate(140)for safetycarousel_slide.html—{{ slide_text|default(hook) }}+ slide indicator + conditional "swipe →" affordance
-
POST /api/v1/posts/{id}/renderbody gainsslide_index: int = 1(Pydantic validatesge=1, le=20). - Tests: 23 new — 13 in
test_generator.py(4-stage pipeline assertions,_parse_visual_extracttolerance suite,extract_visual_fieldscall shape) + 10 intest_renderer.py(extracts in context, slide_index clamping/picking, template branch selection, metric fallback typography signature). Full suite 110 passing in 1.71s. - End-to-end smoke against a fresh
ai_automationdraft: Stage 4 returned 5 carousel slides + a non-hook pull_quote + correct null on metric_value (no real number to invent). Rendered all 4 templates + 5 carousel slides via real Playwright + real Supabase. Spotted + fixed the metric_card overflow bug mid-smoke (added smart auto-pick fallback + tightened fallback typography). Re-rendered → fits cleanly. - Backwards compat verified: posts predating this Chunk have
extracts=NULL; renderer falls back to hook everywhere and lazy-fills via Stage 4 on first re-render (visible in therenderer.lazy_extractlog line).
-
frontend/app/drafts/[id]/page.tsxnow fetches/posts/{id},/posts/{id}/assets, and/templatesin parallel (Promise.all) on mount. New types:Template,GeneratedAsset,TemplateCategory. - New "Visual" section sits above the Hook section. Layout: square preview (left) + template picker dropdown + Render button (right). Picker default is "Auto for category" → backend picks via
_DEFAULT_TEMPLATE_BY_CATEGORY. Picking an explicit option posts{template_id}to/render. - Latest asset rendered via plain
<img>(cross-origin from backend's/static/assets; no Next.js Image config needed). "Open full size →" link to the underlying URL. - Older renders collapse into a
<details>thumbnail grid (3-5 cols responsive) — each render produces a newGeneratedAssetso the user can scrub history. - Render button state: idle / "Rendering…" / disabled-while-in-flight. Errors surface inline below the button as monospace text.
- Type-check clean (
npx tsc --noEmitreturns 0). - Reachability verified: backend
GET /api/v1/templates,GET /posts/{id}/assets,POST /posts/{id}/renderall return 403 unauthenticated (auth gating). Frontend/drafts/{id}returns 200 (page compiles + renders). Static asset URL serves 200. - Browser e2e — user-driven check. Open a draft → click Render → wait ~3-5s → image appears in the preview slot. Pick a different template, click Re-render, verify a new image replaces it and the previous one moves into the "earlier renders" details strip.
Chunked: A (editing) → B (approve + LinkedIn preview) → C (schedule + queue pages).
- Backend:
PATCH /posts/{id}with{hook?, body?, tags?}body — gated onstatus='draft'(returns 409 otherwise per the no-skip lifecycle). Tag normalization helper (lowercase, strip leading#, trim, dedupe, cap at 6, max-length 40) applied server-side so manual edits stay within the voice guideline. - Backend: 8 new unit tests for
normalize_tags(lowercase + hash strip, empty/whitespace drop, order-preserving dedupe, hash-strip-then-dedupe, MAX_TAGS cap, MAX_TAG_LENGTH truncation, non-string skip, empty input). Full suite 118 passing in 1.91s. - Frontend:
components/DraftEditor.tsx— extracted client component. Auto-resizing textareas for hook + body (ref + scrollHeight effect), tag chip input (Enter or comma to add, Backspace on empty input pops last chip, × to remove), dirty tracking via memo (compares hook/body/tags vs initial), Save disabled until dirty + non-empty + not saving. - Frontend:
/drafts/[id]/page.tsxwires the editor — newediting/saving/editErrorstate,apiPatch<Post>call on save (updates page state from response + exits edit mode), Cancel discards. Edit button on the action bar is disabled with a tooltip whenpost.status !== 'draft'. Visual + metadata sections stay visible during edit; only the read-only Hook + Body sections are swapped out. - TypeScript clean (
npx tsc --noEmit). New PATCH route registered (verified via router enumeration). - Browser e2e — user-driven check. Open a draft → click Edit → tweak hook + body + tags → Save → page re-renders with updated values + exits edit mode. Try a non-draft post: Edit button shows disabled with the lock tooltip.
- Backend:
POST /posts/{id}/approve—draft → approvedper the no-skip lifecycle; returns 404 if missing, 409 with"cannot approve post in status 'X'"if not draft. Returns refreshedPostRead. Verified registered alongside the other/postsroutes via router enumeration. - Frontend:
components/LinkedInPreview.tsx— feed-card chrome with avatar (initials gradient), name + "· 1st", title line ("Founder · Nestwhirr" by default), "now · 🌐", body withwhitespace-pre-wrapso generator line breaks survive, hashtags rendered in LinkedIn blue (#0a66c2), single-image OR carousel slot (carousel shows slide 1 + "1 / N" badge + "swipe →" affordance), stub reactions row + Like/Comment/Repost/Send action bar. Not pixel-perfect — signal is "line breaks, hashtags, image-hang", not chrome accuracy. - Frontend:
/drafts/[id]/page.tsxwires both:- LinkedIn preview section between Visual and Hook. Uses
latest?.image_urlfor single-image posts, falls back tocurrentCarousel(the Chunk G memo from Phase 4) when N>1 carousel slides exist. Author name pulled from Supabase session email's local part. - Approve button on the action bar — blue CTA. Disabled with status-aware tooltip when
post.status !== 'draft'(shows "Already approved" / "Cannot approve a post in status 'X'"). Label transitions to "Approved ✓" once flipped. After approve,setPost(updated)swaps the status badge AND auto-disables the Edit button (Chunk A lifecycle rule still holds).
- LinkedIn preview section between Visual and Hook. Uses
- TypeScript clean (
npx tsc --noEmit). Full backend suite 118 passing in 1.92s (no new tests — approve endpoint has no pure logic worth unit-testing beyond the status gate, which is one branch). - Browser e2e — user-driven check. Open a draft → see the LinkedIn preview render with the latest image and full body → click Approve → button flips to "Approved ✓", status badge turns blue, Edit greys out, action bar unchanged otherwise.
- Backend:
POST /posts/{id}/schedulebody{scheduled_at: datetime}— acceptsstatusin{approved, scheduled}so the same endpoint handles initial schedule AND reschedule (idempotent). Naive datetimes are tagged UTC at the boundary. Rejects times withinMIN_SCHEDULE_LEAD_SECONDS=60sof now (400). Setspost.scheduled_at+ flips status toscheduled+ commits. APScheduler job registration is intentionally deferred to Phase 6 — this endpoint just persists the intent. - Frontend:
components/SchedulePicker.tsx—<input type="datetime-local">in the user's local TZ (label shown), default value isnow + 15 minrounded to the next 5-min mark, client-side validation mirrors the 60s server gate with a small skew buffer (70s), live relative-time hint ("in 2 hr"), submits as an explicit UTC ISO string viatoISOString(). Re-uses initial value when rescheduling. - Frontend:
/drafts/[id]/page.tsxwires the picker — newscheduling/schedulePickerOpen/scheduleErrorstate, blue Schedule / Reschedule button on the action bar (enabled only when status in{approved, scheduled}), inline picker panel above the action row, scheduled-time banner stays visible once set. After a successful schedule, the page state flips status toscheduledand the badge + action bar update. - Frontend:
/scheduledpage — list view, fetchesGET /posts?status=scheduled&limit=100, sorts client-side byscheduled_atASC (next-up first), shows category badge + status badge + scheduled date + relative time, click-through to detail. - Frontend:
/publishedpage — list view, fetchesGET /posts?status=published&limit=100, sorts bypublished_atDESC. Each row links to the LinkedIn post via a smalllinkedinUrlFromUrn()helper that convertsurn:li:share:NNN/urn:li:ugcPost:NNNto the publichttps://www.linkedin.com/feed/update/urn:li:activity:NNN/URL (LinkedIn redirects the activity-id form regardless of source URN). Empty state points back to/scheduledsince nothing publishes until Phase 6. - Frontend: Dashboard cards — replaced the "Coming soon" placeholder with separate Scheduled and Published cards, kept a thinner "Coming soon" stub pointing at Phases 6 + 7.
- Test suite still 118 passing in 1.93s. TypeScript clean. New routes verified live in the running backend via OpenAPI: PATCH /posts/{id}, POST approve, POST schedule all registered + auth-gated (unauthenticated returns 403). New frontend routes serve 200:
/scheduled,/published. - Browser e2e — user-driven check. Approve a draft → click Schedule → pick ~5 min from now → save → status flips to
scheduled, indigo banner shows, /scheduled page lists it next-up. Reschedule → time updates. /published is empty until Phase 6 lands the publisher. - Optimistic UI on approve/schedule (deferred — small enough latency on localhost that the server-roundtrip read is cleaner than rollback complexity)
Chunked: A (LinkedIn client) → B (publisher orchestration) → C (scheduler wiring + endpoints) → D (e2e smoke).
- Web-verified current LinkedIn API specs against
learn.microsoft.com/.../li-lms-2026-05(Posts API, Images API, MultiImage API). Endpoints:POST /rest/images?action=initializeUpload→ returns{uploadUrl, image};PUT {uploadUrl}withContent-Type: application/octet-streamand the raw bytes;POST /rest/postsreturns201with the new URN in thex-restli-idresponse header. Required headers on all calls:Authorization: Bearer …,X-Restli-Protocol-Version: 2.0.0,LinkedIn-Version: YYYYMM,Content-Type: application/json(for JSON bodies). -
app/publishing/linkedin_client.py— pure-HTTP surface (no DB).LinkedInAPIErrorcarriesstatus+bodyso the publisher can categorize retryable vs permanent failures. Module-level constants for endpoint URLs and a 30sHTTP_TIMEOUT. Two header helpers (_post_headers,_image_init_headers) so future per-endpoint header tweaks don't leak. -
escape_commentary(text)— backslash-escapes LinkedIn little-text reserved chars (\<>#~_*[](){}@|). Plain#hashtagis left as-is; LinkedIn auto-converts to the{hashtag|#|x}template on read per the docs. (The escape is defensive — if the generator ever emits(or@it won't break parsing.) -
author_urn(account_kind, member_sub=, org_urn=)—personal → urn:li:person:<sub>,organization → urn:li:organization:<id>. RaisesValueErroron missing fields or unknown kind. - Image upload primitives —
initialize_image_upload(token, owner_urn) → InitializeUploadResult(upload_url, image_urn, expires_at_ms),upload_image_binary(upload_url, png_bytes), and the high-levelupload_image(token, owner_urn, png_bytes) → image_urnthat composes both. - Post-payload builders —
build_text_post_payload,build_image_post_payload(image_urn, alt_text?),build_multi_image_post_payload(images=[(urn, alt?)])enforcing the LinkedIn 2-20 image constraint. All wrap_post_envelopewhich encodes the shared shape (visibility=PUBLIC,feedDistribution=MAIN_FEED,lifecycleState=PUBLISHED,isReshareDisabledByAuthor=false). -
create_post(token, payload) → urn— POSTs to/rest/posts, expects 201, reads URN fromx-restli-idresponse header. RaisesLinkedInAPIErroron non-201 or missing header. -
refresh_access_token(refresh_token_plain)re-exports the existingoauth.refresh_tokenso the publisher imports everything LinkedIn-related from one module._is_token_stale(expires_at)with a 5-min refresh lead; tolerates naive datetimes by tagging them UTC. - Tests: 25 new in
tests/publishing/test_linkedin_client.pycovering escape, author_urn (5), initialize_image_upload (happy + 403 + missing-field), upload_image_binary (PUT shape + failure), composed upload_image (POST-then-PUT order), payload builders (text, image with/without alt, multiImage 2-image happy + too-few + too-many), create_post (URN from header + 422 + missing-header), token staleness (close/far/naive). Realhttpx.AsyncClientis patched viahttpx.MockTransport— no network. Full suite 143 passing in 2.04s.
-
app/publishing/publisher.py— DB-orchestration layer overlinkedin_client.publish_post(session, post_id) → Post. Lifecycle (per CLAUDE.md, no skipping):{approved | scheduled} → publishing → {published | failed}. Status flip topublishingcommits before any HTTP work so a concurrent observer sees the lock. -
ensure_fresh_token(session, account) → plaintext_token— decrypts; if_is_token_stalereturns True and a refresh_token exists, callslc.refresh_access_token, re-encrypts access + (rotated) refresh, persistsexpires_at, returns the new plaintext. No-op for accounts without a refresh token. -
_select_publish_assets(session, post)— non-carousel: latest GeneratedAsset. Carousel: contiguous batch ofcarousel_slideassets within a 60s window of the newest, returned slide-1-first. Matches the frontend Phase 4 Chunk G batch detection so what the operator previews inLinkedInPreviewis what ships. -
_download_png(public_url)via httpx (bucket is public; no SDK round-trip needed). Permanent-fail on non-200. -
_build_commentary(post)—hook + \n\n + body + \n\n + #tag #tag. Prose is passed throughlc.escape_commentary(defensive against()@etc.); hashtags appended raw so LinkedIn auto-templates them on read. - Retry policy:
MAX_ATTEMPTS=3, exponential backoff (2s, 4s) between attempts._classify(LinkedInAPIError)returns one of{auth, transient, permanent}. 401/403 → force-refresh (setsexpires_at = now) + retry once; subsequent 401/403 → permanent. 429/5xx → transient + backoff. Other 4xx → permanent. OnePublishingLogrow per attempt regardless of outcome (status=success|failure, response body truncated to 4 KB, error to 500 chars). Final post.error includes the HTTP status code so the operator dashboard can group failures. -
_load_personal_account(session)— MVP single-user lookup (latestaccount_kind=personal). When multi-tenant lands, replace with auser_idjoin fromPost. - Tests: 24 new in
tests/publishing/test_publisher.py— pure helpers (_build_commentaryescape + tag append + no-tags;_classifyparameterized across 11 status codes;_account_author_urn),ensure_fresh_token(fresh decrypts and skips refresh; stale refreshes + re-encrypts + flushes), fullpublish_postorchestration (text-only happy; single-image happy; carousel-multi-upload-each; wrong-status raises; missing post raises; permanent 4xx marks failed after one attempt; transient 429 then 200 succeeds on attempt 2; transient 503 exhausts MAX_ATTEMPTS; auth 401 forces refresh + succeeds on attempt 2). Full suite 167 passing in 1.94s. All viaAsyncMocksessions + monkeypatched HTTP — no DB, no Supabase, no LinkedIn.
-
app/scheduler/jobs.py—publish_post_job(post_id: str)opens its ownAsyncSessionLocal(jobs run outside FastAPI's session-injected scope) and callspublish_post.publish_job_id(post_id) → "publish:<uuid>"is the stable per-post job id so reschedule/cancel target the same row.schedule_publish(scheduler, post_id, run_date)registers/replaces aDateTriggerone-shot withreplace_existing=True+ 1hmisfire_grace_timeso a worker that restarted during the fire window still publishes.cancel_publish(scheduler, post_id) → boolremoves any pending job for the post. Late-importspublish_postinside the job to keep scheduler startup lean. -
POST /posts/{id}/schedulenow also callsschedule_publish(get_scheduler(), post.id, sched)after the DB commit. Reschedule path is idempotent — samepublish:<id>id withreplace_existing=Trueswaps the trigger. -
POST /posts/{id}/publish-now— bypasses scheduler. Gates onstatus ∈ {approved, scheduled}, callscancel_publish(...)first so any pending APScheduler one-shot can't double-fire, then runspublish_post(session, post_id)inline. Returns refreshedPostReadon success; raises 502 withpost.erroronstatus=failedso the UI can show the LinkedIn error inline. - Frontend
drafts/[id]/page.tsx— new emerald "Publish now" button on the action bar (enabled whenstatus ∈ {approved, scheduled}). One-lineconfirm()gate so the user can't fat-finger a real LinkedIn post. Error surfaced inline below the action bar in a red mono panel. ReusesapiPost<Post>like the other transitions. Status badge + action-bar disabled states auto-update from the returnedPost. - Removed the "Publishes once the Phase 6 worker is wired" placeholder in the scheduled-banner; now reads "The scheduler will publish to LinkedIn at this time."
- Full test suite still 167 passing in 2.00s. Routes verified live via OpenAPI:
/posts/{id}/publish-nowregisters. TypeScript clean (npx tsc --noEmit). - Browser e2e — user-driven check. Open an approved post → click Publish now → confirm → ~5-15s later button shows "Published ✓" equivalent (status badge flips, linkedin_urn populates) OR red error panel shows the LinkedIn status code. For the scheduled path: schedule a post 90s out, leave the backend running, watch for the
scheduler.publish.firelog line + the post.status transitioning automatically.
- Publish-now → real LinkedIn post. Verified live 2026-06-02. Draft → approve → render → Publish now → urn
urn:li:share:7467618890898919424returned in thex-restli-idheader, persisted toposts.linkedin_urn, post status flipped topublished. The PNG was fetched from Supabase Storage, uploaded to LinkedIn's DMS endpoint (201), and the post was created via/rest/posts(201). Round-trip ~14s from button click to status flip. - Scheduled APScheduler fire → real LinkedIn post. Verified live 2026-06-02. Draft → approve → schedule for ~90s → backend logs showed
scheduler.publish.fireat the right time → publisher ran → post published. Thepublish:<id>APScheduler job persisted viaSQLAlchemyJobStoresurvives backend restart and fires deterministically. - Token-refresh path verified as unreachable on a standard LinkedIn dev app. Forced
expires_at = now() + 1 minutevia SQL → publish-now succeeded but the refresh log lines (publisher.token.refreshing/publisher.token.refreshed) never appeared. Root cause:refresh_token IS NULLon thelinkedin_accountsrow →ensure_fresh_tokenshort-circuits before checking staleness. LinkedIn doesn't issue refresh tokens with justw_member_socialscope — that's only granted to apps approved for the Marketing Developer Platform (same legal-entity + manual-review path as Community Management API). The refresh code is correct; it just has nothing to refresh. Operational reality: access tokens last ~60 days; at day ~60 the access token dies and the operator must re-run the OAuth flow ("Reconnect LinkedIn") to get a fresh 60-day window. Phase 8 hardening should surface a "token expiring in N days" banner on the dashboard so day-60 isn't a surprise.
- Wired
voice.polish_voice()intorun_pipeline(). New constantPOLISH_ESCALATE_THRESHOLD = 2in generator.py. Whenstrip_ai_tellsreports ≥2 violations, the voiced text is re-run through the fast-model polish pass before Stage 3 extract. Addedtest_polish_escalates_only_above_thresholdcovering both branches; updatedtest_voice_strip_runs_between_stage_2_and_3to handle the newpolishstage. 54 intelligence tests passing (was 53).
Blocked on LinkedIn scope gating. Web-verified 2026-06-02 (per CLAUDE.md "always verify third-party APIs" rule) before writing any code:
r_member_social(the READ scope that gates/rest/socialActions/{shareUrn}for likes + comments on your own posts) is a CLOSED permission in 2026 — LinkedIn isn't accepting new access requests "due to resource constraints." Our app hasw_member_social(write — what we publish with) and OpenID only.- Every richer engagement endpoint (impressions, clicks, time-series) lives behind
r_organization_social/ Community Management / Marketing Developer Platform — same legal-entity + manual-review gate that blocks org posting and refresh tokens. - The only
Defaulttier (no approval needed) product that returns post engagement is Member Data Portability API — but it's a member-authorized data-export API (returns dumps on a delay), not a live-stats endpoint. Viable as a future Phase 7 substrate; needs its response shape web-verified before scoping.
Path forward: request Member Data Portability access in parallel; revisit Phase 7 with a daily-export-driven design if/when it lands. Until then, jump to Phase 8.
Original (deferred) scope, kept for reference:
-
app/analytics/collector.py— polls LinkedIn share statistics for recent posts -
analytics_collection_job()— hourly cron -
GET /analytics/posts/{id}endpoint - Frontend:
/analyticspage with per-post time-series, top hooks, top categories
- "LinkedIn token expires in N days" banner on dashboard. frontend/app/page.tsx gains a
tokenExpiry(expires_at)helper + a top-of-dashboard banner. Three tiers driven offexpires_atfrom/auth/linkedin/status(no backend change needed — that field was already returned in Phase 1):- silent when > 10 days remain
- amber ("expires in N days") at ≤ 10 days, with a yellow Reconnect CTA
- red ("expires in N days" or "expired N days ago") at ≤ 3 days or already past, with a red Reconnect CTA + copy explaining standard dev apps don't issue refresh tokens (so the day-60 manual re-OAuth is the reality, not a bug).
- Prominent Reconnect button. The banner's button is the new primary affordance; the existing small underline "Reconnect" inside the LinkedIn section stays as the always-available secondary path. Both call
connect()→/auth/linkedin/start. - Duplicate
linkedin_accountscleanup — N/A. The initial schema migration (ba1ce855d31a_initial_schema.py:160) already enforcesUniqueConstraint('user_id', 'account_kind', name='uq_linkedin_accounts_user_kind'), so true duplicates can't exist. Cross-user rows from dev testing are independent rows, not duplicates — leaving them alone (destructive cleanup not justified for MVP). - TypeScript clean (
npx tsc --noEmit). - Browser e2e — user-driven check. Force
expires_atin Supabase tonow() + 2 days→ red banner with red Reconnect CTA. Set tonow() + 8 days→ amber banner. Set tonow() - 1 day→ red "expired" banner.
- Backend:
GET /posts/{id}/logsreturns allPublishingLogrows for a post, newest-first.PublishingLogReadexposesattempt,status,error,linkedin_response,attempted_at. - Backend:
POST /posts/{id}/retryresets afailed → approvedpost (clearspost.error, commits) then re-runspublish_postinline. 409 if status isn'tfailed; 502 on a fresh failure. The publisher's own{approved, scheduled}lifecycle gate is preserved — retry can't bypass it, it just flips status first. - Frontend
/drafts/[id]: whenpost.status === 'failed', renders a red panel with the error message + a prominent Retry publish button + collapsible Attempt history showing each publishing_logs row (attempt #, status, timestamp, error). The tinyError: …line on the metadata card is gone; replaced by aLinkedIn URNrow when published. - Logs auto-fetch when
post.status ∈ {failed, published, publishing}; silently no-op otherwise. - TypeScript clean; backend test suite 168 passing.
- Browser e2e — user-driven check. Force a failure (e.g., revoke the LinkedIn token in Supabase, click Publish now) → red panel shows with
post.error+ Retry button + attempt log. Fix the token, click Retry → status flips topublished.
- APScheduler restart-survival verified live 2026-06-02. Smoke: scheduled a post 3 min out, killed uvicorn, waited 30s, restarted.
scheduler.started jobs=['publish:03aad8d7-…', 'retrieval']confirmed theSQLAlchemyJobStorerehydrated the one-shot. Job fired on time (scheduler.publish.fireat 17:58:00 UTC), LinkedIn returned 201, post status flipped topublishedautomatically. - Full pipeline smoke verified live 2026-06-02. Trends refresh → draft generation → render → approve → schedule → APScheduler fire → published — all green on a fresh run.
- Token-refresh edge cases tested (expired token, revoked grant) — only meaningful once we have a refresh token to test with (requires Marketing Developer Platform approval). Permanently deferred.
- README rewritten. Reflects Phase 6 / 8 state, lists each subsystem with a status column, fixes the Quick Start bug (no root
.env— backend readsbackend/.env, frontend readsfrontend/.env.local). Adds an "Operational Reality Check" callout for the 60-day LinkedIn token cycle, Phase 7 deferral, and Community Management gating.
- URN-encoding bug:
GET /rest/documents/{urn}returned 400 because LinkedIn's Rest.li requires URL-encoded URNs in path keys (colons →%3A). Docs example shows it unencoded but the actual endpoint rejects that. Fixed inget_document_statusviaurllib.parse.quote(..., safe=""). - Slide-order bug: PDF carousels published in reverse (3/3, 2/3, 1/3). Root cause:
TimestampMixin.created_atuses Postgresserver_default=func.now(), which in a single transaction resolves once at transaction start. All three carousel slides got identicalcreated_at, soorder_by(created_at.desc())returned them in implementation-defined order and the publisher's subsequentbatch.reverse()flipped that into the wrong order. - Fix: new nullable
GeneratedAsset.slide_indexcolumn (Alembic migration559724258d97_add_slide_index_to_generated_assets.py).render_post_carouselnow setsslide_index=ion every carousel asset (1-based)._select_publish_assetsreplaces thebatch.reverse()withbatch.sort(key=lambda a: a.slide_index or 0)— authoritative ordering, no clock-reliance. Single-image renders leaveslide_index=NULL; the column is purely informational outside the carousel path. - Legacy carousel assets in the DB have
slide_index = NULL(no info encoded in their rows). They'd publish in unstable order if used; re-render any carousel draft you want to publish to get the proper indexing. (Also re-renders pick up the Chunk G safe-zone template fix in the same step.) - Backend suite 172 passing.
- Why: MultiImage posts render as a 1+N image grid on LinkedIn, not a swipeable carousel — and LinkedIn's grid layout aggressively crops each tile, clipping text on the slides we ship. The real native swipeable carousel is a Document post (PDF →
/rest/documents+/rest/postswithcontent.media). - Web-verified the Documents API at learn.microsoft.com (li-lms-2026-05):
POST /rest/documents?action=initializeUploadreturns{uploadUrl, document, uploadUrlExpiresAt}; PUT the PDF bytes to uploadUrl; pollGET /rest/documents/{urn}untilstatus=AVAILABLE(LinkedIn explicitly does NOT support SYNCHRONOUS_UPLOAD here); thenPOST /rest/postswithcontent.media.{id, title}.w_member_socialscope is sufficient for person-owned documents. img2pdf==0.6.3added to requirements.txt (lossless PNG → PDF, no re-encoding). Web-verified latest stable.- New module
app/rendering/pdf.py— single publicbuild_pdf(pngs) -> bytesthat runs the CPU-bound assembly inasyncio.to_threadto keep the event loop free. - Extended
linkedin_client.pywithinitialize_document_upload,upload_document_binary,get_document_status,wait_for_document_available(2s interval, 90s timeout),upload_document(composed init+upload+poll), andbuild_document_post_payload. Status-transition errors propagate asLinkedInAPIErrorso existing retry/classification works unchanged. publisher._attempt_publishmulti-asset branch rewritten — assets with len ≥ 2 → download all PNGs → assemble PDF →lc.upload_document→build_document_post_payload. Every slide'sGeneratedAsset.linkedin_asset_urngets stamped with the same document URN so the dashboard can trace which document any slide belongs to.- carousel_slide.html safe-zone fix — outer 1200×1200 canvas now centers an 840×1080 inner content box. text-6xl → text-5xl. Heads will fit inside the safe area even after aggressive downstream cropping.
- Tests:
test_publish_post_multi_image_uploads_each→ renamedtest_publish_post_multi_slide_uploads_document, asserts the document path (no image uploads, PDF builder called once, document upload called once, every asset row gets the document URN). Patched fixture now stubspdf_assembler.build_pdf+lc.upload_document. Backend suite 172 still passing.
- Bug: publishing a carousel-rendered post shipped only slide 1 to LinkedIn unless
post.category == carousel. The frontend's "Render carousel" button works on any post (it dispatches on the chosen template, not the category), so any carousel rendered via the template dropdown on a non-carousel post published as single-image. - Fix:
publisher._select_publish_assetsnow detects a carousel by asset shape, not by post category — front-of-list cluster ofcarousel_slideassets withinCAROUSEL_BATCH_WINDOW_SECONDSwith ≥ 2 entries → multi-image; otherwise → single asset. Mirrors the frontendcurrentCarouselmemo so what the operator previews is what ships.PostCategoryimport dropped (no longer needed). - Stage 4 prompt tweak: carousel_slides instruction reworded from "An array of 3 to 5 slides" → "Default to 3 slides; only add a 4th or 5th if the post genuinely contains that many distinct beats worth their own slide. Do NOT pad to hit 4 or 5." Should reduce the always-4 default toward content-matched counts.
- Backend suite still 172 passing.
- Bug: the live LinkedIn post showed the hook twice and hashtags twice. Root cause was a contract mismatch: Stage 3's extract prompt told the LLM
"body": the FULL post text including the hook, verbatim, sobodycarried the hook + trailing#tag #tagline. Then_build_commentarycomposedhook + body + tag_line— both leaked twice. The LinkedIn preview happened to renderbody + tags(no hook prepended) so the preview also disagreed with what shipped. - Fix (defense in depth, both sides):
- Stage 3 prompt (
generator._EXTRACT_SYSTEM) updated to requirebodyexclude the hook and any trailing hashtag line, with an explicit warning that including them causes duplication on render. generator.clean_body(hook, body)— idempotent deterministic helper that strips a leading duplicate hook line (case-insensitive, whitespace-tolerant) + trailing hashtag-only lines. Applied inside_parse_extractso new drafts persist clean.publisher._build_commentarynow runsclean_bodybefore composition, so legacy drafts (wherebodystill has the hook + tags inline) publish cleanly without re-generation.LinkedInPreviewnow takes ahookprop and rendershook + "\n\n" + bodyso the preview matches the live post exactly.
- Stage 3 prompt (
- Tests: 2 generator tests updated to the new body shape; 2 new generator tests for the leading-hook-strip and trailing-hashtag-strip; 2 new publisher tests proving
_build_commentarydeduplicates legacy bodies. Backend suite 172 passing. - Legacy drafts in the DB still have the old body shape — they publish correctly via the publisher's defensive cleanup, but the dashboard's read-only Body section will still show the duplication until they're regenerated. Acceptable: the data flowing out to LinkedIn is correct; the cosmetic backfill isn't worth a one-shot migration.
Goal: give the operator flexibility (delete trends, delete drafts, unapprove, unschedule) without breaking the no-skip forward lifecycle in CLAUDE.md. All changes are local-state only — no external surface (LinkedIn, Bluesky, etc.) is mutated by this phase.
Explicitly out of scope: deleting published posts from LinkedIn. Discussed and dropped — too risky, low real demand. If it ever becomes needed, it's one new client method + endpoint + a typed-confirmation modal, no schema change.
- Backend:
DELETE /trends/{id}(single) +POST /trends/bulk-deletebody{ids: [...]}. - Backend: daily sweeper job
cleanup_consumed_trends_job()registered in scheduler/jobs.py — deletestrendsrows whereconsumed_at < now() - 60 days. Keeps the table from compounding without touching unconsumed pool. - Frontend: trash icon per row on
/trends; multi-select checkbox column + "Delete N selected" CTA in the toolbar. - Tests:
BulkDeleteRequestschema validation + sweeper cutoff math (tests/api/test_trends.py). Endpoint behavior covered by manual smoke per repo convention.
- Backend:
DELETE /posts/{id}— gated onstatus ∈ {draft, failed}. Returns 409 with explicit message forapproved/scheduled(operator must reverse lifecycle first). Cascade togenerated_assets+publishing_logsconfirmed already present in initial migration;ai_generationscorrectly SET NULL. - Backend:
POST /posts/{id}/unapprove—approved → draft, no external side effects. - Backend:
POST /posts/{id}/unschedule—scheduled → approved. Callscancel_publish(scheduler, post.id)from scheduler/jobs.py so the APScheduler one-shot drops cleanly. - Frontend: contextual buttons on
/drafts/[id]action bar — "Revert to draft" appears whenstatus=approved, "Cancel schedule" appears whenstatus=scheduled, "Delete draft" appears whenstatus ∈ {draft, failed}. Edit + Schedule buttons re-enable correctly after a revert (driven bypost.statuscheck that already exists). - Frontend: hover-trash on
/draftslist rows, gated bystatus ∈ {draft, failed}. - Tests: lifecycle gate invariants for all three endpoints (tests/api/test_posts_lifecycle.py). Scheduler interaction covered by manual smoke per repo convention.
- Full reversal loop on a real draft: draft → approve → schedule → unschedule → unapprove → edit → delete. Status badge, action bar, and APScheduler job state all flip correctly at each step.
- Trend delete + sweeper round-trip: 65d-old consumed row swept, fresh unconsumed + 5d-old consumed preserved (verified inline against dev DB on 2026-06-11).
- Bulk-delete UI on
/trends: select 5 rows → delete → list re-fetches → all 5 gone.
Goal: add a second renderer backend (diffusion via API) alongside the existing Playwright template path. Templated cards stay primary (on-brand per CLAUDE.md). AI image is opt-in per render — operator picks "AI image (Flux)" from the same template dropdown that already exists.
- Alembic:
generated_assets.render_kindenum ('template','ai_image'); default'template'for all existing rows. (migration8a3180a8960e, applied) - Refactor rendering/renderer.py: split into
_render_template(...)(current path) and_render_ai_image(...)(stub raising NotImplementedError until Chunk B+D). Publicrender_post(...)gainskind: RenderKind = RenderKind.template. - New module
app/rendering/ai_image/withinterface.pyexposingImageGeneratorABC (async def generate(prompt: str) -> bytes).
- Web-verify both endpoints + response shapes against current docs before code (CLAUDE.md rule). Confirmed: model ID
@cf/black-forest-labs/flux-1-schnell, endpoint/accounts/{id}/ai/run/{model}, response{result.image: base64}; Pollinationsimage.pollinations.ai/prompt/{q}?model=fluxreturns raw image bytes. -
app/rendering/ai_image/cloudflare.py— FLUX-1-schnell via Workers AI REST. Free 10k neurons/day (~260 images). Env varsCLOUDFLARE_ACCOUNT_ID,CLOUDFLARE_AI_TOKENalready inbackend/.env.example. Config knobs:AI_IMAGE_MODEL,AI_IMAGE_STEPS=4,AI_IMAGE_TIMEOUT_SECONDS=30. -
app/rendering/ai_image/pollinations.py— GET-URL Flux call, no auth. Validatescontent-type: image/*to detect HTML error pages. -
app/rendering/ai_image/__init__.py—generate_image(prompt, primary=?, fallback=?)composer: try Cloudflare, on any exception (includingCloudflareConfigError) fall back to Pollinations. Logsai_image.primary_failed+ai_image.fallback_used. - Tests: 12 tests in
tests/rendering/test_ai_image.py— Cloudflare happy/missing-creds/HTTP-error/success-false/missing-image; Pollinations happy/non-image/HTTP-error; composer primary-ok/primary-http-fail/primary-config-fail/both-fail.
- Added Stage 5 to
generator.run_pipeline()— FAST modelllama-3.1-8b-instant, JSON output, temp 0.4, 200 max_tokens. Returns one 12-25 word visual prompt aligned with the [[project_visual_style]] minimal-dark brand identity. - Persists to
Post.extracts.ai_image_prompt(no new column; JSONB already absorbs new fields). - Lazy backfill:
_ensure_ai_image_prompt(session, post)runs Stage 5 inline and writes ontopost.extractsfor any pre-Phase-10 post on first AI-image render. Same shape as Stage 4 lazy-extract. - Cost target: <$0.0002/post on
llama-3.1-8b-instant(200 max_tokens × $0.05/1M = ~$0.00001). Well under budget. - Tests: 9 new —
_parse_ai_image_prompt(well-formed / bad JSON / non-dict / missing key / empty / whitespace),extract_ai_image_prompt(happy path / bad JSON fallback / empty fallback).run_pipeline.test_full_five_stage_flowupdated to assert all 5 stages in order with right models. - Brand-safe fallback
_AI_IMAGE_PROMPT_FALLBACK("minimalist editorial illustration on a near-black background…") used when Stage 5 emits unparseable or empty output, so the renderer always has a usable prompt.
- New HTML template
backend/app/rendering/templates/ai_image_wrap.html— eyebrow + centered 800×800 AI image inset (ring + rounded) + brand strap inside a 1200×1200 black canvas. Matches [[project_visual_style]] minimal-dark identity. - Migration
3b27fdcf2346— adds'ai_image'value totemplate_categoryenum + seeds theai_image_wraprow intemplates. Idempotent (ON CONFLICT, ADD VALUE IF NOT EXISTS, COMMIT before ALTER TYPE). -
_render_ai_imageend-to-end: lazy Stage-5 prompt resolve →generate_image()(Cloudflare → Pollinations) → base64 data URI → Jinja2 renderai_image_wrap.html→ Playwright PNG → Supabase upload →GeneratedAssetrow withrender_kind=ai_image. - API:
RenderRequest.kind: RenderKind(default'template');POST /posts/{id}/renderpasseskindthrough.GeneratedAssetRead.render_kindexposed so frontend can label assets. - Tests: 2 new Jinja2 renders (brand-wrap happy + HTML-escape on hook). Suite at 209.
- Drafts detail template dropdown gains "✦ AI image (Flux)" option above the regular template list.
ai_image_wrapis filtered out of the regular list (operator picks it indirectly via the AI option). Sentinel value__ai_image__triggerskind: "ai_image"on/render; carousel branch + slide index are bypassed. - Asset metadata + earlier-renders thumbnails show a violet "AI image" badge when
render_kind === "ai_image", so both styles read clearly side-by-side. - Manual E2E #1: render templated card → swap to AI image → new asset appears with violet "AI image" badge. Earlier-renders strip shows both styles. (PASS, 2026-06-13)
- Manual E2E #2: publish a draft whose latest asset is an AI image — confirm the brand-wrap composite ships and renders cleanly on the actual LinkedIn post. (PASS, 2026-06-13)
Goal: end the LinkedIn-only assumption. Same draft fans out to multiple selected platforms via the Publisher ABC. Threads + Instagram ship code-complete but stay behind feature flags until Meta App Review clears (precedent: [[project_linkedin_org_posting]]).
Scope: single phase, four chunks. Each chunk plugs into the abstraction landed in Chunk A.
- Alembic: new
post_targetstable —(id, post_id, platform, status, external_urn, scheduled_at, published_at, error, raw_request).postskeeps draft content;post_targetsis per-platform fan-out. (c9e1f02db4a7 applied 2026-06-15 — 12 LinkedIn rows backfilled from published/failed/scheduled posts.) -
posts.linkedin_urn→ deprecated in favor ofpost_targets.external_urn. Column kept nullable + still stamped from the first LinkedIn target for one release. Removal slated for Phase 12 cleanup. -
PublisherABC formalized in publishing/base.py;LinkedInPublisherextracted into publishing/linkedin.py. Oldpublishing/publisher.pydeleted. -
BlueskyPublisher— AT Protocol viaatproto==0.0.62(pre-1.0, exact pin). App-Password auth, not OAuth — atproto Python SDK is still session-based pre-1.0; OAuth migration deferred. Posts toapp.bsky.feed.postviaClient.send_post/Client.send_images. 300-char limit + 4-image cap (orchestrator gates char_limit, BlueskyPublisher truncates carousel slides ≥5). - Connect flow:
POST /auth/bluesky/connect+GET /auth/bluesky/status+DELETE /auth/bluesky/disconnect. No browser redirect — handle + app password go to backend, atproto validates, Fernet-encrypted at rest. Newbluesky_accountstable mirrorslinkedin_accountsshape minus token-refresh fields. - Scheduler:
publish_target_job(target_id)replacespublish_post_job(post_id). One APScheduler job per target. - Frontend: per-platform status badge strip (color-coded by
TargetStatus); Bluesky connect section on dashboard alongside LinkedIn. Approve/schedule/publish-now/retry/unschedule all fan out per-target. (Platform multi-select moved from approve to /generate page on 2026-06-16 after the first live publish hit Bluesky's 300-char gate. See [[#2026-06-16-phase-11-chunk-a-iteration]] in CONTEXT.md.) - Iteration 2026-06-16: platform picker moves pre-generation. Generator runs
compress_for_blueskyinline so each platform ships in its own shape.PostTarget.commentary(migrationd4f6b8e1029c) stores the per-platform rewrite. Dashboard error panel now surfaces per-target errors. +12 tests (compress_for_bluesky happy/empty/over-cap/quote-strip, canonical resolver, build_platform_commentary, orchestrator's target-commentary fallback). - Tests: 34 new across
tests/publishing/test_orchestrator.py,test_linkedin_publisher.py,test_bluesky_publisher.py— char-limit gate, retry classification, account loader, atproto-mocked happy + sad paths, Bluesky carousel truncation. (220 backend tests passing.)
- Per-instance OAuth — user supplies their instance URL (e.g.
mastodon.social,hachyderm.io) on connect; normalized + stored onmastodon_accounts.instance_url. Newmastodon_accountstable (migratione2a7c4b96d18, noexpires_at/refresh_token— Mastodon tokens don't expire by default). - Federated → dynamic app registration.
POST {instance}/api/v1/appsat connect time (no central registry);client_id/client_secretcarried through the Fernet-encrypted OAuthstate(TTL-checked), so the callback is stateless — nomastodon_appstable. -
MastodonPublisher(publishing/mastodon.py) — raw httpx (no SDK). Two-step media:POST /api/v2/media(multipart, handles 200-sync + 202-async-poll) →POST /api/v1/statuseswithmedia_ids[]+Idempotency-Key.char_limit=500,max_images=4. Orchestrator wiring: one register-import + one_load_accountbranch. - OAuth:
/auth/mastodon/start?instance=<url>+/callback+/status+/disconnect(api/auth_mastodon.py). Access token Fernet-encrypted at rest. Web-verified API against docs.joinmastodon.org per CLAUDE.md. - Frontend: Mastodon connect section on the dashboard (instance input → OAuth redirect, status + disconnect);
mastodonadded to the/draftsplatform multi-select. Per-target status-badge strip from Chunk A renders it automatically. - STRICT char-limit (no
compress_for_mastodon) per memory [[project-per-platform-generation-strategy]] (user-confirmed). Mastodon targets getcommentary=None→ fall back to LinkedIn-shaped body → trip the 500-char gate loudly until Phase 12's per-platform prompt matrix lands. Intentional pressure, not a bug. - Tests: 30 new in tests/publishing/test_mastodon_publisher.py — normalize_instance, state round-trip, register_app/exchange_code/verify_credentials, media sync + async-poll, carousel truncation to 4, error classification (401/403/429/503/422/400), account loader. Full suite 262 passing. tsc clean. Migration applied to Supabase; all 4 routes registered.
- Live (user-driven): connect a real Mastodon account via the dashboard → OAuth round-trip →
mastodon_accountsrow. Generate a draft targeting Mastodon → approve → publish-now → confirm the target fails the 500-char gate with the clear error (expected until Phase 12). A hand-shortened ≤500-char draft publishes end-to-end.
- Submit Meta App Review in parallel with code. Two permissions:
threads_basic,threads_content_publish. Per [[project_linkedin_org_posting]] this gate is the same shape — code now, approval later. Pre-approval the publisher works only against the developer + tester accounts in the Meta app. (User action — code ships ahead of approval.) -
ThreadsPublisher(publishing/threads.py) — raw httpx, two-step container API. Create containerPOST {graph}/{ver}/{user-id}/threads(media_type TEXT/IMAGE/CAROUSEL), poll the containerstatusuntil FINISHED (media is processed async), thenPOST .../threads_publishwithcreation_id. Carousel = N×is_carousel_itemIMAGE containers (each polled) → parent CAROUSEL container withchildren.char_limit=500,max_images=20(carousel 2–20). Threads fetchesimage_urlfrom our public Supabase bucket itself (no byte download). Error classification 401/403→Auth, 429/5xx→Transient, other 4xx→Permanent, plus a body sniff for Meta'sOAuthException/code 190 (returned as 400 for a dead token) → Auth. - OAuth (publishing/threads_oauth.py + api/auth_threads.py) — single fixed Meta app (LinkedIn-shaped, not federated).
/auth/threads/startreturns the authorize URL;/callbackexchanges the code for a short-lived token, immediately upgrades to a 60-day long-lived token (th_exchange_token), resolves the profile (/me), and persists./status+/disconnectround it out. Web-verified endpoints against developers.facebook.com (two sources) per CLAUDE.md. - Token refresh is in place, no separate refresh_token.
threads_accounts(migrationf1b3d7a8e564) carriesexpires_atbut norefresh_token— ThreadsGET /refresh_access_tokenreturns a fresh access token.ThreadsPublisher._ensure_fresh_tokenrefreshes proactively when within a 2-day lead of expiry. The orchestrator's forced-refresh-on-401 branch guards onhasattr(account, "refresh_token"), so a hard 401 degrades to terminal-after-retries (operator reconnects) — same clean degradation as Mastodon. - New env var
PLATFORM_THREADS_ENABLED(defaultfalse). Backend gates/auth/threads/start(503 when off) and/statusreturnsenabled: false. Frontend uses thatenabledflag as the source of truth — the dashboard Threads connect section AND the/generateplatform picker both hide Threads when off (no separate frontend env var). - Orchestrator/scheduler/posts API needed zero changes —
Platform.threadsalready existed and the publish/schedule/retry/aggregate paths are platform-agnostic. Wiring was one register-import + one_load_accountbranch. - Tests: 28 new in tests/publishing/test_threads_publisher.py — state round-trip, authorize URL, exchange_code/exchange_long_lived/refresh/fetch_profile, token staleness + proactive refresh, publish text/single-image/carousel/truncate-to-max, container ERROR-status → permanent, error classification (incl. OAuthException-in-400 → Auth), account loader. Full suite 290 passing. tsc clean. Migration applied to Supabase; all 4 routes registered.
- Live (user-driven, post-approval): flip
PLATFORM_THREADS_ENABLED=true, connect a Threads tester account → OAuth round-trip →threads_accountsrow with a 60-dayexpires_at. Generate a ≤500-char draft targeting Threads → approve → publish-now → confirm the container creates, polls FINISHED, publishes, and the media id lands on thepost_targetsrow.
- User converts Instagram → Creator account. 30 seconds in IG mobile settings, free, reversible. Required because personal IG accounts cannot publish via API (web-verified June 2026). Creator account skips the legacy Facebook Page link requirement. (User action.)
- Submit Meta App Review for the Instagram content-publish scopes. Note: the Instagram Login flow uses
instagram_business_basic+instagram_business_content_publish(the Facebook-Login variant isinstagram_basic/instagram_content_publish); we use the Instagram-Login naming since that's the mandated flow. Scopes are env-driven (INSTAGRAM_SCOPES) so a rename is a one-line change. (User action — code ships ahead of approval.) -
InstagramPublisher(publishing/instagram.py) — raw httpx, two-step container API. CreatePOST {graph}/{ver}/{ig-user-id}/media(single image:image_url+caption; carousel: N×is_carousel_itemimage containers → parentmedia_type=CAROUSEL+children), poll the containerstatus_codeuntil FINISHED, thenPOST .../media_publishwithcreation_id. No text-only post — empty assets →PermanentPublishError(IG requires media, unlike Threads).char_limit=2200,max_images=10(carousel 2–10). IG fetchesimage_urlfrom our public Supabase bucket itself. Error classification mirrors Threads (401/403→Auth, 429/5xx→Transient, other 4xx→Permanent, + OAuthException/code-190 body sniff → Auth). - OAuth (publishing/instagram_oauth.py + api/auth_instagram.py) — Meta's Instagram Login flow (NOT Facebook Login — the lightweight path that skips the Facebook-Page link).
/auth/instagram/startreturns the authorize URL;/callbackexchanges the code for a short-lived token (+ IG-scopeduser_id, used as the publish path segment), upgrades to a 60-day long-lived token (ig_exchange_token), resolves the profile (/me?fields=user_id,username,account_type), and persists./status+/disconnectround it out. Code-exchange body handles both flat{access_token,user_id}and wrapped{data:[…]}shapes. Web-verified against developers.facebook.com (two sources) per CLAUDE.md. - Token refresh is in place, no separate refresh_token (same as Threads).
instagram_accounts(migrationa3c8e0f5471b) carriesexpires_atbut norefresh_token—GET /refresh_access_token?grant_type=ig_refresh_tokenreturns a fresh access token.InstagramPublisher._ensure_fresh_tokenrefreshes proactively inside a 2-day lead; the orchestrator's forced-refresh-on-401 guard (hasattr(account,"refresh_token")) degrades a hard 401 to terminal-after-retries. - New env var
PLATFORM_INSTAGRAM_ENABLED(defaultfalse). Backend gates/auth/instagram/start(503 when off) and/statusreturnsenabled:false. Frontend uses thatenabledflag as the source of truth — the dashboard Instagram connect section AND the/generateplatform picker both hide Instagram when off (no separate frontend env var). - Orchestrator/scheduler/posts API needed zero changes —
Platform.instagramalready existed; wiring was one register-import + one_load_accountbranch. - Tests: 30 new in tests/publishing/test_instagram_publisher.py — state round-trip, authorize URL, exchange_code (flat + wrapped body + missing-fields), long-lived/refresh/profile (incl. legacy
idfield), token staleness + proactive refresh, publish requires-image gate, single-image (asserts nomedia_type,captionparam), carousel (ITEM1,ITEM2,ITEM3), truncate-to-max, container ERRORstatus_code→ permanent, error classification (incl. OAuthException-in-400 → Auth), account loader. Full suite 320 passing. tsc clean. Migration applied to Supabase; all 4 routes registered. - Live (user-driven, post-approval): convert to Creator account, flip
PLATFORM_INSTAGRAM_ENABLED=true, connect via Instagram Login →instagram_accountsrow with a 60-dayexpires_at. Generate a draft targeting Instagram, render a card (IG requires an image), approve → publish-now → confirm the container creates, polls FINISHED, publishes, and the media id lands on thepost_targetsrow.
- New generic PlatformPreview.tsx — platform tab strip across the draft's targets, dispatching to per-platform card variants: LinkedIn reuses the existing rich
LinkedInPreview; Bluesky/Mastodon/Threads share a text-firstMicroblogCard(platform-accented handle + hashtags + single-image hang); Instagram gets an image-firstInstagramCardthat flags the no-image case (IG can't post text-only — surfaces the would-be publish-gate failure inline). Not pixel-perfect by design. Wired into drafts/[id]/page.tsx replacing the LinkedIn-only block; reads each target'scommentary(per-platform rewrite) or falls back to the canonical hook+body+tags. - Inline char-limit warning:
PLATFORM_LIMITSmirrors eachPublisher.char_limit(LinkedIn 3000, Bluesky 300, Mastodon/Threads 500, Instagram 2200). Live char count per tab; over-cap shows an amber banner naming the cap + that the long-term fix is per-platform generation (Phase 12), not truncation, and notes when the target is falling back to the LinkedIn-shaped body. - Code-level E2E (tests/publishing/test_orchestrator.py
test_fan_out_persists_distinct_urns_per_platform): one draft → LinkedIn + Bluesky targets → each publishes via its own publisher and persists a distinctexternal_urn, proving the fan-out + per-target persistence are platform-independent. Suite 321 passing, tsc clean. - Live E2E (user-driven): one draft → target LinkedIn + Bluesky simultaneously → approve → publish-now → both URNs persist on their respective
post_targetsrows; both posts go live. (Requires real connected accounts — goes live, can't run from CI.) - Live E2E (Meta-gated, user-driven): same draft against Threads/Instagram (tester accounts, pre-approval) → publishes against the Meta dev sandbox once the flags are flipped + App Review clears.
- Sixth platform, wired through the existing seams. New
Platform.xenum value +x_accountstable (migrationd7e4a1f9c2b8; enum add in anautocommit_blockso the seed can reference it).XPublisher(app/publishing/x.py) registered → orchestrator_load_accountgains one branch, zero other changes. char_limit=280, max_images=4. - OAuth 2.0 Authorization Code + PKCE (
app/publishing/x_oauth.py+app/api/auth_x.py):/startgenerates the PKCE pair and carries the verifier in the encrypted state; token endpoint uses HTTP Basic auth (confidential client); short-lived (~2h) access token + rotating refresh token viaoffline.access—_ensure_fresh_tokenrefreshes proactively and re-persists BOTH (Fernet-encrypted). Web-verified against docs.x.com (2026-07). - Publish = upload-then-tweet (X does NOT fetch image URLs): per asset, download bytes → one-shot
POST /2/media/upload(multipart,media_category=tweet_image, under 5 MB so no chunking) →POST /2/tweetswithmedia.media_ids; 201 →data.idstored asexternal_urn. Needsmedia.write+tweet.writescopes. - Seeded 8 X rewrite prompts (migration
e1f8b3c6a4d9), one per generatable category, Bluesky-shaped 280-char. All 8 categories now uniform across linkedin/bluesky/mastodon/threads/instagram/x. - Gated behind
PLATFORM_X_ENABLED(default false) like the Meta platforms — the only paid platform (~$0.015/post, no free tier for new devs 2026). Connect flow 503s + frontend hides picker/connect section until a paid X app is provisioned. User-confirmed 2026-07-08 to build full API posting (not manual-assist). - Frontend: X in
lib/domain.ts(type/label/280 limit),PlatformBadgedot+tab colors,PlatformPreview(reusesMicroblogCard), Settings → Connections X section,/draftsgated picker option, Today callback banner, PublishedtargetUrl(x.com/i/web/status/{id}). tsc clean. - Tests: 27 new in
tests/publishing/test_x_publisher.py(PKCE/state round-trip, Basic-auth token exchange, rotating-refresh, profile, staleness, ensure-fresh rotation, text/single-image/max-image publish, error classification, loader). 375 backend tests passing. Migrations applied live to Supabase (enum + table + 8 prompt rows verified). - Char-cap overshoot fix ✅ (2026-07-08): live-testing the X content quality surfaced that dense sources push the 280-char X rewrite over budget (9/12 over on a number-packed source) + a latent
KeyError(PLATFORM_CHAR_LIMITSmissingPlatform.x→ every X generation would crash). Fixed the map, and added a bounded shorten-retry torun_platform_rewrite(re-ask ≤3×, aim below the cap, keep the shortest — never truncate): worst-case dropped to 3/12, realistic sources ~0/6. Guard test updated. Root-cause follow-up (migrationa2c5e8f1b4d7): X's 280 cap sits in the band where the model naturally lands (Bluesky's 300 gives 20 chars headroom), so tightened the 8 X prompts to aim ~240 (250 ceiling) instead of "under 275" → 0/12 over on the worst-case source, realistic 176–267, still substantive. 379 tests passing. - Manual-post mode (generate-and-paste, no API publish) ✅ (2026-07-08): with
PLATFORM_X_ENABLED=truebut no X account connected, X drafts still generate the 280-char variant and the operator posts by hand. New terminalTargetStatus.skipped(enum migrationf4a9c2e7b1d6) —publish_targetmarks an X legskipped(notfailed) when no account is connected; aggregation excludes skipped legs so the pipeline stays green. Draft cockpitPlatformPreviewgained a Copy text button; the Visual panel gained a download link (fetch→blob save; thedownloadattr is ignored on cross-origin Supabase URLs) per image + carousel slide. Frontend renders skipped as a neutral "manual" chip. +4 orchestrator tests → 379 backend tests passing, tsc clean. Migration applied live. - Live E2E (user-driven): provision a paid X app → set
X_CLIENT_ID/SECRET+PLATFORM_X_ENABLED=true→ connect via Settings → target a draft to X → approve → publish-now → tweet id persists on thepost_targetsrow + goes live. (Requires a real paid X app; goes live + bills, can't run from CI.)
Goal: widen content surface. 4 new categories with seeded prompts. Restructure prompts for (category × platform) matrix so the same draft category produces a long LinkedIn post, a 280-char Bluesky line, a 500-char Threads post, etc. Add 1–2 RSS feeds aimed at the new categories — freshness gate is non-negotiable: any new feed must be publishing in 2026 and posting at least monthly, otherwise it's cut.
- Alembic (
b7d3a1c8e92f):prompts.platformenum column (reuses the existingplatformenum type, nullable for "any/legacy"). Existing 4 rows backfilled toplatform='linkedin'. New unique constraintuq_prompts_name_platform_version(name, platform, version). Applied to Supabase. - Matrix lookup:
_load_active_prompt(session, category, platform)prefers the per-platform variant, falls back toplatform IS NULL, raises if neither. Canonical pipeline loadsplatform='linkedin'. New_load_platform_prompt(exact-platform, no NULL fallback) drives the per-target commentary rewrite. - Stage-2 per-platform rewrite is now DB-driven via
run_platform_rewrite(replaces the hardcodedcompress_for_blueskyinline stopgap per memory [[project-per-platform-generation-strategy]])._build_platform_commentaryloads the per-platform prompt; LinkedIn → None (canonical Post); no-prompt fallbacks: Bluesky → legacycompress_for_bluesky, Mastodon/Threads/Instagram → None (trips char-gate loudly until seeded). Per-platform tone (LinkedIn 140–220w / Bluesky ≤280 / Threads ≤500 conversational / Mastodon ≤500 engineering / Instagram hashtag-heavy caption) lives in the seeded prompt bodies (Chunk B). Over-cap output is NOT truncated — loud gate failure is the signal to fix the prompt. - Tests: 12 new (
tests/intelligence/test_prompt_matrix.py+ updatedTestBuildPlatformCommentary) — matrix prefer-platform, NULL fallback, platform=None skip, raise-when-neither, exact_load_platform_prompt,PLATFORM_CHAR_LIMITSmap, rewrite within-cap / no-truncate-over-cap / Instagram long-caption. 333 backend tests passing.
-
dev_tooling— anchors: Will Larson (internal platforms as products), Gergely Orosz / Pragmatic Engineer (DORA over LOC) -
pricing_business_model— anchors: Ben Thompson / Stratechery (aggregation, free tiers), Stripe (usage-based pricing) -
product_postmortem— anchors: rachelbythebay-style blameless incident writeups (root cause is the system, rehearse the failure) -
design_thinking— anchors: Julie Zhuo (design = decisions), NN/g (research before pixels) - Alembic migration
c5f9a26b81e4seeds each — 4 categories × 5 platforms = 20 prompt rows, all v1, allis_active=true. Verified live (20 rows, 5 per category, all platforms, all active). The LinkedIn variant is a Stage-1 angle/writer prompt (long-form founder voice, v4 shape); the Bluesky/Threads/Mastodon/Instagram variants are Stage-2 rewrite prompts with a per-platform anchor honoring that platform's char budget. Enum prerequisite handled: the 4 newpost_categoryvalues are added inside anautocommit_blockso the seed INSERTs can reference them (Postgres same-transaction restriction). - Drafts UI category dropdown grows from 4 to 8 (
GENERATABLE).PostCategorytype union +CATEGORY_LABEL+CATEGORY_COLORSextended across all 4 pages that render category chips (drafts, drafts/[id], scheduled, published) so tsc stays green. tsc exit 0. - Follow-up (2026-06-17, migration
e8b4c1f7d320): backfilled the 16 missing per-platform rewrite prompts for the original 4 categories (founder_insight/engineering_lesson/ai_automation/startup_opinion × bluesky/threads/mastodon/instagram) reusing their v4 anchor voices. All 8 generatable categories now uniform on the matrix → 40 active prompt rows (8 × 5).compress_for_blueskydemoted to pure safety net. (technical_humor/carouselremain prompt-less + absent fromGENERATABLE— latent, not reachable.)
- Web-verified each candidate feed live (2026-06-17) per CLAUDE.md. Survivors fetched + confirmed publishing in 2026.
- Verified + added (3): Lethain.com
https://lethain.com/feeds/(newest 2026-06-15), Julia Evanshttps://jvns.ca/atom.xml(newest 2026-05-15), NN/ghttps://www.nngroup.com/feed/rss/(newest 2026-06-12). Cut (2): rachelbythebay — blocks non-browser bots (404 to our UA on both feed + site; our retrieval bot can't read it); a16z/feed/— 404 + duplicates the Stratechery/Stripe business/pricing coverage already in the list. - Per-feed 25-cap + 90-day age cutoff already in place — no new code. Freshness gate enforced by retrieval.
- Category dropdown extended (Chunk B); per-platform previews from Phase 11 Chunk E automatically consume the right prompt variant —
PlatformPreviewreads each target'scommentary, which now comes from the DB matrix. No code change needed. - E2E freshness check (run live): fetched the 3 new feeds → 27 items within the 90-day cutoff, 11 within 30 days (Lethain 06-15, NN/g 06-12/06-05, etc.). New sources return recent
fetched_at. ✅ - E2E matrix smoke (user-driven, live): generate
dev_toolingfor Bluesky → short punchy output; same category for LinkedIn → 140–220-word post. Needs live Groq tokens + trends in the pool; the matrix dispatch is proven at the code level (test_prompt_matrix.py+TestBuildPlatformCommentary), the subjective output-length check goes live with a real generation.
- Admin UI for runtime-defined categories is deferred if real demand surfaces. Seeded-via-migration keeps voice quality controlled by anchor examples; a free-form UI risks generic AI-spam output (violates CLAUDE.md philosophy). No schema change needed later — prompts table is already data-driven, so when/if this lands it's a UI + write endpoint, not a refactor. (When it lands it likely rides inside Phase 13's Settings → Prompts surface.)
Phase 13 — UI/UX Redesign (AutoPost — by Nestwhirr) ✅ (chunks A–I code-complete 2026-06-23; browser e2e user-driven)
Target spec lives in DESIGN.md (the why + shape — read it first). This section is the progress tracker only; do not duplicate the spec here. Chunks mirror DESIGN.md §Build order. Each chunk: land it, tick it, then record the architecture delta in CONTEXT.md.
Direction (locked 2026-06-22): single-operator editorial cockpit, not a SaaS dashboard. Studio mirrors the rendered-card identity (minimal-dark + serif display). Left-rail IA with a brand switcher (multi-company seam, now). Pipeline rendered literally (Today funnel / Drafts board / Calendar). Three-pane draft cockpit. Fully responsive. App name brand-neutral ("AutoPost"); "Nestwhirr" only as vendor + selected brand.
Chunk A — App shell + design tokens (DESIGN.md §2, §3) ✅ (2026-06-22, code-complete; browser e2e user-driven)
- Rewrote
frontend/app/globals.css— dropped the Next.js light/dark + Arial boilerplate; installed the Tailwind-v4@themetoken set (surface/ink/parchment-accent/pipeline-status) + the serif system stack (ui-serif, Georgia…— matches the cards verbatim, no webfont) / Geist-sans / Geist-mono. Dark-only,color-scheme: dark, hairline scrollbars. - Persistent left-rail nav (
components/shell/NavRail.tsx) — Today/Inbox/Drafts/Calendar/Published/Settings, inline line-art glyphs (no emoji), active state viausePathname, "needs me" badges (Inbox = unconsumed trends, Drafts = draft+failed, fetched once per shell mount, silent-fail). - Brand/workspace switcher (
components/shell/BrandSwitcher.tsx) at rail top — single static "Nestwhirr" entry + "Add brand — coming soon"; the multi-company seam is the deliverable (real tenancy = Phase 14). - Global token-health pill (
components/shell/TokenHealthPill.tsx) —tokenExpiry()extracted tolib/tokenExpiry.ts(shared with the future Today screen); silent >10d, amber ≤10d, red ≤3d, Reconnect CTA. Collapses to a single dot on the icon-rail. - Responsive shell (
components/shell/AppShell.tsx): full rail ≥1280px → icon-only rail 768–1280 (driven by amatchMediahook, SSR-safe since the rail only renders for an authenticated session) → bottom tab bar <768 (MobileTabBar, drops Settings to stay at 5 thumbs)./login+ unauthenticated state render bare. Wired into rootlayout.tsx; uses a<div>content column (pages keep their own<main>)./settingsstub added so the nav link resolves pre-Chunk-I. -
npx tsc --noEmitclean. - Browser e2e (user-driven): sign in → rail + brand switcher + badges render; resize to confirm full→icon→bottom-bar tiers;
/loginshows no rail; token pill appears amber/red when the LinkedIn token is near expiry.
- Single source of truth
lib/domain.ts—PostStatus/PostCategory/Platformtypes +CATEGORY_LABEL+GENERATABLE+PLATFORM_LABEL+PLATFORM_LIMITS. Kills the type/label/cap duplication that lived in every list page. -
StatusPill(components/ui/StatusPill.tsx) — semantic pipeline-status tokens, literal per-status class map (Tailwind v4 can't see dynamic class names), single status dot (publishing pulses). Consolidation done:drafts,scheduled,publishedmigrated toStatusPill+CategoryChip+lib/domainimports; their localCATEGORY_COLORS/STATUS_COLORSrainbow maps deleted.drafts/[id]intentionally left — Chunk C rewrites it as the cockpit, so migrating it now would be thrown away. -
CategoryChip(components/ui/CategoryChip.tsx) — neutral hairline chip, not rainbow (editorial restraint; colour is reserved for pipeline state). ReadsCATEGORY_LABEL. -
PlatformBadge+PlatformTab(components/ui/PlatformBadge.tsx) — per-platform identity; a platform's hue is real brand identity (small colored dot) with neutral chrome.PlatformTabis the cockpit tab-strip button (Chunk C). -
CharBudgetMeter(components/ui/CharBudgetMeter.tsx) — thin track filling toward the cap, accent → amber (≥85%) → red (over), readsPLATFORM_LIMITS. -
VoiceQualityChip(components/ui/VoiceQualityChip.tsx) — presentational; green "Voice clean" / amber "N tells" (+polished). The live client-side scanner feeding it lands in Chunk C. -
Banner+ToastProvider/useToast(components/ui/Banner.tsx,Toast.tsx) — one inline-alert component, four tones on the semantic tokens; toasts built on Banner, mounted once byAppShell. Replaces the pattern of the ~8 hand-rolled banners. -
ActionBar+ActionButton(components/ui/ActionBar.tsx) — sticky action-row container + variant buttons (primary=parchment accent, secondary, danger, ghost) with loading spinner; Chunk C composes the real Edit/Render/Approve/Schedule/Publish bar. -
CommandBar(components/shell/CommandBar.tsx) — global ⌘K/Ctrl-K palette: filterable nav jumps + safe actions (generate→drafts, refresh trends→/trends/refreshwith a toast), arrow/enter keyboard nav. Mounted inAppShell. -
npx tsc --noEmitclean. - Browser e2e (user-driven): ⌘K opens/filters/navigates; status pills + neutral category chips render on drafts/scheduled/published; a toast appears on "Refresh trends".
Chunk C — Draft Review Cockpit (DESIGN.md §5C) — the proving vertical slice ✅ (2026-06-23, code-complete; browser e2e user-driven)
- Three-pane layout (Editor · Platform Preview center · Rail) replacing the stacked
/drafts/[id]sections. Grid[0.95fr · 1.35fr · clamp(280px,24vw,360px)]atlg; tablet two-pane toggle (md); mobile stacks preview-first → editor → rail. - Editor pane: live client-side voice flagging via new
lib/voice.ts(mirrorsstrip_ai_tellsphrase list 1:1) +HighlightFieldoverlay (transparent textarea on a highlighted backdrop → wavy-red underlines that never drift off the caret). Serif hook. Inline tell list +VoiceQualityChip(count = distinct tells, matching backendlen(violations)). - Preview pane (center):
PlatformPreviewpromoted + refactored onto the kit — always-onPlatformTabstrip +CharBudgetMeter; over-cap →Bannerpointing at per-platform generation. Reads the live working copy so an edit shows its per-platform consequence immediately. - Rail: status/category/targets + Visual render slot + template picker + carousel strip (Phase-4 Chunk-G batch logic preserved) + failed-panel/retry/attempt-history.
- Sticky status-aware
ActionBar(Save/Approve/Schedule/Publish/Retry/Delete,confirm()on publish-now + lossy reverts; success → toasts). Inline editing replaces the old Edit-mode toggle; working copy lifts to the page so editor + preview stay in sync. - Responsive: stacks preview-first → editor → sticky ActionBar on mobile; tablet pane toggle.
-
tsc --noEmitclean; new components (voice.ts,HighlightField,PlatformPreview) lint clean. (Page keeps the pre-existing data-fetchset-state-in-effectbaseline shared by every list page.)
Chunk D — Today (command center) (DESIGN.md §5A) ✅ (2026-06-23, code-complete; browser e2e user-driven)
- Replaced the home card-wall (
app/page.tsx): token-health band (amber/red only, reuseslib/tokenExpiry) + 3 worklist counters (Needs review / Scheduled / Published-7d, deep-linked) + pipeline funnel strip (Inbox → Draft → Approved → Scheduled → Live, each a deep-link with live counts) + fresh-trends peek (top 5 unconsumed) + primary "Generate a draft" CTA. Surfaces the?<platform>=connected|errorOAuth callback (callbacks still land on/) as a dismissible Banner. - Connections relocated early (necessary so Today can shed the card-wall without stranding platform setup): the 5 connection sections moved verbatim into
components/settings/Connections.tsx, rendered byapp/settings/page.tsxwith a Connections·Prompts·Templates·Voice sub-nav (latter three shown "soon" — Chunk I). This satisfies Chunk I's first bullet ahead of schedule. -
tsc --noEmitclean. Render kept pure (clock snapshotted once via lazyuseState); only the shared data-fetchset-state-in-effectbaseline remains in lint.
Chunk E — Drafts pipeline board (DESIGN.md §5D) ✅ (2026-06-23, code-complete; browser e2e user-driven)
- Status-column board (Draft/Approved/Scheduled/Published) replacing the flat
/draftslist; cards show hook (serif) + category + platform badges + voice chip; failed = red + retry.- Four columns map 1:1 to the lifecycle:
publishingrides the Scheduled column (in-flight),failedrides the Published column rendered red with a Retry button (POST /posts/{id}/retry). EveryPostStatusstays visible across four columns. - Voice chip per card via the client
scanVoiceTellsmirror (lib/voice.ts), violations computed once in auseMemobucketing pass so cards stay presentational. - Generate strip kept at the top of the board (restyled to tokens) — Today's "Generate a draft" CTA lands here. Delete affordance on draft/failed cards; optimistic remove + toast.
- Responsive:
grid-cols-1 sm:grid-cols-2 xl:grid-cols-4(mobile stacked → tablet 2×2 → desktop 4 across).
- Four columns map 1:1 to the lifecycle:
- Week/month grid replacing the flat
/scheduledlist; platform-colored blocks; drag-to-reschedule through the idempotent/scheduleendpoint; mobile → agenda list.- Month/Week view toggle + ‹ Today › navigator. Month = 6×7 grid (off-month days dimmed); Week = 7 tall columns. Today ringed; past days dimmed and drop-disabled.
- Blocks are native-draggable (HTML5 DnD, no new dep); left accent bar = primary platform color (
PLATFORM_DOTnow exported fromPlatformBadge). Drag to another day reschedules preserving time-of-day, optimistic update +POST /posts/{id}/schedule(idempotentreplace_existing), reverts on error. Client guard rejects slots <now + 60s(backendMIN_SCHEDULE_LEAD_SECONDS). - Right rail "Next up" = chronological scheduled list (preserves the old next-up-first sort). Empty-cell hover "+" deep-links to
/drafts(generate-into-slot affordance). - Responsive: grid is
hidden md:block; on mobile the "Next up" rail becomes the full-width agenda. Route stays/scheduled(NavRail already labels it "Calendar").
- Evolve
/trendsinto a dense triage list; keyboard nav (j/k,e,g); per-row Generate → deep-link into the cockpit with the trend pre-selected.- Dense email-triage rows (not cards): left filter strip (Status · Source as button lists), source-coded dot + HN score + relative age, optimistic consume toggle kept, per-row delete on hover.
- Keyboard:
j/k+arrows move a cursor (active row tinted, scrolls into view),econsume,ggenerate-from-row,⏎open source URL. Window listener ignores form-control focus + modifier keys. - Generate → deep-links to
/drafts?trend=<id>&t=<title>. The board's generate strip (nowSuspense-wrapped foruseSearchParams) reads the pin, shows a dismissible "Generating from this trend" Banner, includestrend_ids:[id]inPOST /posts/generate, and on success routes straight to the new draft's cockpit (/drafts/<newId>). Non-pinned generate keeps the old stay-on-board behavior. - Refresh + filters preserved; toasts replace the old flash strip. tsc clean; only the baseline data-fetch
set-state-in-effectlint remains.
- Evolve
/published: per-row fan-out targets + outcomes; reserved (empty) "Performance" column for the deferred Phase-7 analytics — no fake charts.- Each row reads
post.targets[]: one chip per platform with a status-coded outcome (published = ✓ / outbound ↗ where a public URL is reconstructable; failed = red "· failed" with the error in the tooltip). Partial fan-out shows the failed leg red alongside the live ones. - Per-platform public links via a
targetUrl(platform, external_urn)helper: LinkedIn (/feed/update/{urn}), Bluesky (at://→bsky.app/profile/<did>/post/<rkey>), Mastodon (the storeduri/urlif http). Threads/Instagram store opaque media ids → outcome shown without a link. Legacy posts (onlylinkedin_urn, no targets) fall back to a single LinkedIn chip. - Reserved Performance column: a real grid column with a header but an em-dash cell — signals the Phase-7 slot without faking metrics (
r_member_socialclosed in 2026; tooltip says "deferred until Member Data Portability lands").sm:grid-cols-[1fr_150px]; collapses to single column on mobile.
- Each row reads
- Move the 5 connection sections out of home into Settings → Connections (verbatim, keep Meta-gated
enabledhiding). Done early in Chunk D (components/settings/Connections.tsx). - Settings → Prompts (read/activate the versioned
promptstable) + Templates + Voice rules surfaces.- Settings is now tabbed via
?tab=(Suspense-wrapped foruseSearchParams): Connections · Prompts · Templates · Voice. - Backend (new):
app/api/prompts.py—GET /prompts(full matrix) +POST /prompts/{id}/activate(flipsis_activewithin the (name, platform) group so the generator's lookup resolves to one live row; NULL-platform matched withIS NULL). Registered inapp/api/__init__.py. Read + activate only — authoring bodies stays a migration concern (anchor examples hold voice quality). No schema change, no migration. - Prompts.tsx — groups versions by (name, platform), highlights the live one, Activate buttons on the rest (optimistic flip + toast), collapsible body view.
- Templates.tsx — read-only grid over the existing
GET /templates(no per-templateis_active; selection is per-post at render). Thumbnail + name + category + html_path. - VoiceRules.tsx — read-only reference: the
BANNED_PHRASESlist (same array that drives the cockpit's live flagging, imported fromlib/voice.ts) + the structural/formatting rules from CLAUDE.md. Editing = code change, by design.
- Settings is now tabbed via
Phase 14 — Invite-Only Access + Per-Workspace Multi-Tenancy ✅ (2026-07-20; Chunks A–I complete — code-done, live invite-email + browser QA are user-driven)
Turns the tool from "single-operator, all data global" into invite-only + isolated per-user workspaces. Full rationale, the approaches considered, and every locked decision live in CONTEXT.md → Decisions made → 2026-07-19 (read it first — do not re-litigate the choices here). This section is the progress tracker only.
Locked decisions (2026-07-19, user-confirmed): isolated per-user workspaces · hybrid gate (disable Supabase signup + app access_requests + inviteUserByEmail) · fuller marketing landing · dedicated /admin route · SHARED = trends/prompts/templates, PRIVATE = posts+downstream+6 social-account tables · isolation key = workspace_id (1 user/workspace now, team-ready seam).
Standing rules for every chunk: web-verify any Supabase auth-admin API before wiring (CLAUDE.md); id-based fetches must 404 (not 403) on a workspace mismatch (no existence leak); invited users default role editor, never admin.
Chunk A — Lock the door (access gate) — ships the gate first, independent of B–H ✅ (2026-07-19, code-complete; Supabase dashboard toggle is a user step)
-
frontend/app/login/page.tsx:signInWithOtpnow sendsshouldCreateUser: falseso a non-invited email can't self-create; "sent" copy softened to "if you have access…" + a Request-an-invite link. -
backend/app/core/auth.py::get_current_user: no longer hardcodesrole=admin. Invite-only gate — a valid token whose email isn't already inusersand isn't a bootstrap admin → 403 (AccessDeniedError). Bootstrap admins (configBOOTSTRAP_ADMIN_EMAILS, defaultnestwhirr@gmail.com, case-insensitive) are the only JIT self-provision path (→admin) and self-heal toadminif demoted. Addedrequire_admindependency for Chunk F–G.User.rolemodel default flippedadmin → editor(safe-by-default; no migration — Python-side default only). -
GET /api/v1/me→{id, email, role}(newapp/api/me.py, registered).workspace_iddeferred to Chunk B (table doesn't exist yet). - Admin owner locked (user, 2026-07-19): admin =
nestwhirr@gmail.com(new — not yet in DB; JIT-provisions admin on first login). The two pre-existing accounts (shivammaheshwari658@gmail.com,computer.basics.600@gmail.com, both admin-by-default) demoted toeditorvia one-off DB update. - Tests: 7 in
tests/core/test_auth.py(deny unprovisioned, bootstrap JIT→admin, self-heal, no auto-promote, email sync, require_admin allow/block). 390 backend tests pass; frontend tsc clean. - Docs:
INSTRUCTIONS.md"Invite-only access" section (disable-signup toggle + create bootstrap-admin Supabase user + interim manual invites);.env.examplegainsBOOTSTRAP_ADMIN_EMAILS. - User action (manual, Supabase dashboard): turn OFF "Allow new users to sign up" (Authentication → Email) + create the
nestwhirr@gmail.comauth user (Authentication → Users → Add user). Until then the code gate holds but public Supabase signup is still technically open.
-
Workspacemodel (app/models/workspace.py:id,name, timestamps) + registered inmodels/__init__. -
users.workspace_idFK (NOT NULL,ondelete=RESTRICT) +workspacerelationship. JIT bootstrap-admin path incore/auth.pynow creates a workspace before the user (flush → id → user). -
workspace_id(NOT NULL, FK, indexed) added topostsand the 6 social-account tables (linkedin/x/bluesky/mastodon/threads/instagram_accounts). Downstream (generated_assets,post_targets,publishing_logs,analytics) scope through their parent post — no own column.ai_generationsunchanged (audit). -
trends,prompts,templatesunchanged (shared/global). - Data: WIPED, not backfilled (user-approved 2026-07-20 — "don't care about current data"). Migration
d9a1c4e7f012truncatesposts+ the 6 account tablesRESTART IDENTITY CASCADE(clears post children), then gives each existing user their own fresh workspace (name = email local-part). Trends preserved (417). Verified: 3 workspaces (shivammaheshwari658 / computer.basics.600 / nestwhirr-admin), posts=0, accounts=0. -
GET /menow returnsworkspace_id. 390 backend tests pass. -
⚠️ Known interim break (restored in Chunk C/D): writes now requireworkspace_id(NOT NULL), butgenerator.generate_draft(Post) and theauth_*.pyOAuth callbacks (accounts) don't set it yet → post generation + account-connect fail until scoping lands. Expected; do not ship between B and C.
Chunk C — Query scoping (tenant isolation — the IDOR pass) ✅ (2026-07-20, code-complete; live write smoke passed)
-
get_current_workspacedependency added (core/auth.py, returnsuser.workspace_id). - Single choke point
_load_owned_post(session, post_id, workspace_id)inposts.py—session.get+ ownership check → 404 (not 403) when missing OR another workspace (no existence leak). Every id-based endpoint routes through it: get/update/approve/schedule/publish-now/retry/unapprove/unschedule/delete/upload-image, plus a pre-query ownership check on render, render-carousel, logs, targets, assets.list_posts+post_statsfilterWHERE workspace_id = :ws. -
generatethreadsworkspace_id→generate_draft(..., workspace_id=...)sets it on the newPost(restores the B→C generation break). All 15 endpoints switched from_: User = Depends(get_current_user)→workspace_id: UUID = Depends(get_current_workspace); unusedget_current_user/Userimports dropped. - Tests:
tests/api/test_post_scoping.py(3 — match returns, missing→404, other-workspace→404) exercising the IDOR guard directly. 393 backend tests pass. Live smoke: aworkspace_id-scoped Post insert/commit/delete against real Supabase succeeds. -
⚠️ Account-connect still breaks until Chunk D —auth_*.pyOAuth callbacks create account rows withoutworkspace_id. Post generation is restored; connecting a social account is not. Deferred to D by design. - [~] Full httpx AsyncClient + test-DB integration harness remains the deferred Technical-Debt item; the IDOR logic is fully covered by the direct
_load_owned_postunit tests instead.
- All 6 per-platform
load_account(session, post)loaders (app/publishing/{linkedin,bluesky,mastodon,threads,instagram,x}.py) now filterWHERE workspace_id == post.workspace_id(the orchestrator already threadspostin). Prevents an invited user from publishing to another workspace's connected account.# noqa: ARG001removed (post is now used). - OAuth callbacks stamp
workspace_idat connect time — restores the C→D account-connect break. Bluesky (authenticated callback) usesuser.workspace_iddirectly; the 5 redirect callbacks (linkedin/mastodon/threads/instagram/x) load the user by the state'suser_idand setworkspace_id=db_user.workspace_id(redirect?...=error&message=unknown_userif the user vanished). -
status/disconnectroutes stayuser_id-scoped — equivalent to workspace isolation today (1 user/workspace); the publish-path boundary (load_account) is the workspace-scoped one that matters. Future note: when a workspace has >1 member, switch status/disconnect toworkspace_idso teammates see shared accounts. - Tests: 4 publisher
load_accounttests updated to pass a workspace-bearing post; 393 backend tests pass. Live smoke: two workspaces each with a Bluesky account → a post in workspace A loads only A's account (cross-workspace account never returned). - ✅ Full generate → connect → publish loop is workspace-isolated and functional again (B→C→D break fully closed).
Chunk E — Access requests (public intake) ✅ (2026-07-20, code-complete; migration applied + live smoke passed)
-
AccessRequestmodel (app/models/access_request.py:email,name,company,reason,statusenumpending|approved|denied,reviewed_at,reviewed_byFK→users.idondelete=SET NULL, timestamps) +AccessRequestStatusenum inmodels/base.py+ registered inmodels/__init__. Not tenant-scoped — these rows exist before any workspace does. - Migration
70117d5b9596(createsaccess_requests+ix_email/ix_status; hand-stripped the spuriousapscheduler_jobsdrop autogenerate emitted — that table is APScheduler-runtime-managed, not in our models). Applied to Supabase. - Public
POST /access-requests(app/api/access_requests.py) — the ONE unauthenticated write (noget_current_user). Returns an opaque202 {ok:true}in every case so nothing leaks about who's already requested. Guards: honeypot (websitefield — non-empty ⇒ bot ⇒ silent drop), per-IP fixed-window rate limit (5/hour viacore/ratelimit.pyInMemoryRateLimiter— in-process, MVP-appropriate; captcha deferred), email regex (avoids theemail-validatordep), and dedupe (repeat email with an openpendingrow ⇒ no-op).X-Forwarded-For-awareclient_ip. - Router mounted in
api/__init__. Tests:tests/api/test_access_requests.py(9 — validation, happy-path insert+lowercase, honeypot drop, dedupe, rate-limit cap, limiter per-key, ip-helper). 402 backend tests pass (was 393). Live Supabase smoke: honeypot→0 rows, real submit→1pendingrow, dedupe→still 1; cleaned up.
- Web-verified the invite API (CLAUDE.md rule) against supabase-py 2.13.0 + official Python docs:
supabase.auth.admin.invite_user_by_email(email, options={"redirect_to": ...})— sync SDK, service_role, returnsUserResponse.user.id(the Supabase UUID we key the local row on). Sources noted in the chunk report. -
app/core/supabase_admin.py— lazy service_role client (mirrorsrendering/storage.py: module-level client + lock,asyncio.to_threadaround the sync call).invite_user_by_email(email, redirect_to=...) -> UUID; raisesSupabaseAdminErroron misconfig/failure. -
require_admindependency already existed (Chunk B/C) — reused. New sharedprovision_workspace_and_user(session, *, user_id, email, role)helper incore/auth.py(add+flush both rows, no commit — caller owns the txn); the JIT bootstrap-admin path refactored onto it so the two provisioning sites can't drift. -
app/api/admin.py(all routesrequire_admin):GET /admin/access-requests?status=,POST /admin/access-requests/{id}/approve(invite + provision user+workspace roleeditor+ stamp approved; idempotent — already-approved is a no-op, existing local user skips the invite; invite failure → 502),.../deny(DB-only audit stamp),POST /admin/users/invite(direct invite, dup → 409),GET /admin/users,PATCH /admin/users/{id}(role change; last-admin demote guard → 409),DELETE /admin/users/{id}(hard revoke — deletes local user + workspace, cascading posts/accounts; guards self-revoke + last-admin → 409; Supabase Auth identity persists, JIT denies it). Router mounted inapi/__init__. - Tests:
tests/api/test_admin.py(12) +tests/core/test_supabase_admin.py(2) — invite mocked, no network. 416 backend tests pass (was 402). Live Supabase smoke: seeded a pending request →list_access_requests(pending)finds it →denyflips it todeniedwith realreviewed_byFK →list_users=3; cleaned up. - ⏭️ Not live-verified (needs a real invite email): the approve→
invite_user_by_emailpath was exercised only with the Supabase call mocked. First real approve should be smoke-tested by an admin against a disposable inbox (Chunk G/H bring the UI). RequiresSUPABASE_SERVICE_ROLE_KEYset + public signups disabled.
Chunk G — Admin panel frontend (/admin) ✅ (2026-07-20, code-complete; tsc + next build clean; browser e2e user-driven)
-
lib/useMe.ts—useMe()hook fetchingGET /meonce (module-cached; shared by rail, mobile bar, admin page). Exposes{id, email, role, workspace_id}. - Admin nav entry —
NavRailrenders an admin-only "Admin" item (shield glyph) below Settings behind a hairline divider, gated onme.role === "admin";MobileTopBaradds the same link to the account menu (admin-only). Editors/viewers never see it.NavRailrefactored to a sharedRailLinksub-component. -
app/admin/page.tsx— new top-level route, double-gated (unauth →/login; signed-in non-admin →/). Settings-blueprint UI:?tab=sub-nav (Requests · Users), max-w-3xl, minimal-dark. -
components/admin/RequestsQueue.tsx— access-request queue with status filter (Pending default · Approved · Denied · All); approve/deny per row viaPOST /admin/access-requests/{id}/{action}, toast feedback, backend 409s surfaced. -
components/admin/UsersTable.tsx— users table: role<select>→PATCH /admin/users/{id}, Revoke →DELETEwith confirm; self-revoke disabled up front; last-admin/self guards surfaced from backend 409s. - DESIGN.md §2 IA reconciled in the same change (admin rail entry documented — the new-top-level-surface rule). tsc --noEmit clean;
next buildcompiles/admin. Note: the repo-widereact-hooks/set-state-in-effectlint (22 pre-existing hits, a new Next 16 rule the codebase predates) also flags the admin data-loaders — left consistent with the establisheduseCallback+useEffect(()=>load())idiom rather than diverging. - ⏭️ Browser e2e is user-driven (needs an admin login): approve a request against a disposable inbox → verify invite email + provisioned user appears in the Users tab; exercise role change + revoke.
Chunk H — Marketing landing + request form ✅ (2026-07-20, code-complete; tsc + next build clean; live intake smoke passed)
- Root route gated on session (
app/page.tsxHomeGate): logged-out →components/marketing/Landing.tsx, logged-in → Today (unchanged). Removed the old/→/loginredirect. AppShell already renders bare (no rail) without a session, so the landing is full-bleed. -
Landing— four bands in the minimal-dark + serif identity (the studio-looks-like-the-artifact rule): hero (serif display + dual CTA) · what-it-does (4 capability cards: founder-voice/strip-AI-tells, one-approve-six-platforms, retrieval-not-hallucination, rendered-card identity) · how-it-works (theTrends → Draft → Approved → Scheduled → Publishedspine) · request-access. Top bar + footer branded "Nestwhirr Studio". Anchor-scroll CTAs. -
components/marketing/RequestAccessForm.tsx— posts to the publicPOST /access-requestsvia a new no-authapiPostPublichelper (lib/api.ts); fields email (req) + name/company/reason (opt) + honeypotwebsite(off-screen,tabIndex=-1,aria-hidden). Opaque success state ("Request received"), generic error, "Already invited? Sign in" link. - DESIGN.md §2 IA reconciled in the same change (logged-out landing documented — the new-top-level-surface rule). tsc clean;
next buildcompiles (/now static). Live smoke (curl as the form posts): honeypot submit → dropped (no row), real submit →pendingrow with name/company preserved; cleaned up. Browser e2e (visual) is user-driven. - Design pass (2026-07-20, post-review — first cut read generic). Landing rebuilt around the artifact: new
components/marketing/PostCardMock.tsx(a web-scaled replica ofquote_card.html— black field, serif-italic pull-quote, open-quote glyph, hairline rule + uppercase brand strap) anchors the hero (text left / card right + platform fan-out strip). Capabilities → numbered editorial rows (01–04, accent numerals, hairline tops); pipeline → 5-cell bordered grid with per-stage notes. Flat / hairline / no-shadow per DESIGN.md §3. Login page redesigned (was still Next.js boilerplate onzinc-*): split layout — artifact + serif tagline panel (desktop) / magic-link form (tokens, serif, accent) — cross-links to/#request. Both compile clean.
-
CLAUDE.md: lifted the "no multi-tenant logic beyondorg_urn" Forbidden-in-MVP line (approved 2026-07-19) — replaced with a lift-note + a new standing Architectural Rule encoding the tenant-isolation invariants (workspace_id on every private write, 404-not-403 on id mismatch, invited users defaulteditor, only/access-requests+ OAuth callbacks bypass auth). -
DESIGN.md: landing page +/adminalready in the §2 IA (added during G/H); appended thePostCardMock/login-door artifact note so the design pass isn't lagged. -
INSTRUCTIONS.md: "Invite-only access" now covers theSUPABASE_SERVICE_ROLE_KEYrequirement, the/adminpanel walkthrough (requests · users · direct invite), and the seed-via-curlapprove-smoke trick (with the@example.com-rejected caveat). -
CONTEXT.md: per-chunk deltas recorded as each landed (A–H); added a Chunk I closing entry marking Phase 14 complete. - ⏭️ User-driven live verification remaining (not code): real approve→invite-email path against a disposable inbox (mocked in tests); browser visual QA of landing / login / admin.
- Reddit Data API access — submitted to Reddit, awaiting approval. Phase 2 Reddit fetcher is written (
app/retrieval/reddit.py) but stays out of the orchestrator's enabled fetcher list until approval. Activation = one-line import change inorchestrator.py. RSS + HackerNews carry the MVP until approval.
- List pagination — DONE 2026-06-23 (follow-up 2). Count over-fetching was first fixed with
GET /posts/stats+GET /trends/stats+ multi-status?status=a,b. Then "Load more" paging was added (user chose explicit buttons over infinite scroll):offsetparam onGET /posts+GET /trends; Published and Inbox page in 30/50 at a time; the Drafts board shows ≤5 per column with "View all →" (draft/approved expand in place via?view=, scheduled→Calendar, published→Published). Scroll is now contained to each page's list/grid region (shell content column owns the scroll; page headers/filters stay fixed). Not a global framework — just offset + the two surfaces that grow. Cursor pagination remains unnecessary at this scale. - HTTP endpoint test harness (added 2026-06-23). API tests are currently pure-helper + lifecycle-guard unit tests; there's no AsyncClient/DB fixture, so the new stats/multi-status endpoints are covered only by import/route checks. If endpoint logic grows, stand up an httpx AsyncClient + test-DB fixture and backfill integration tests.
- Bluesky hashtag facets (2026-06-17).
#tagsin Bluesky posts were inert plain text — atproto needsapp.bsky.richtext.facet#tagfacets.bluesky._build_richtextnow rebuilds the commentary throughclient_utils.TextBuilderso the generated tags are real/clickable. Mastodon/Instagram/LinkedIn parse or auto-link hashtags natively, so no change needed there. +5 tests (338 total). - Threads hashtag behavior (deferred, Meta-gated). Worth confirming when Threads goes live whether its API auto-links hashtags from
textor needs atopic_tag-style field; if the latter, mirror the Bluesky facet approach in the Threads publisher. - Mobile top app bar (2026-07-01). Settings, Sign-out, token-health reconnect CTA, and the brand switcher were all unreachable on mobile (rail is
hidden md:block; bottom tabs drop them). NewMobileTopBar(md:hidden) restores them;AppShellpads the shell rowpt-14 md:pt-0. DESIGN.md §6 + CONTEXT.md updated. - User-controlled rail collapse (2026-07-01). Left rail was breakpoint-forced with no user control.
AppShellnow persists a collapse pin (localStorage) with a footer toggle +[shortcut and a hover-peek overlay when collapsed; default state still follows the 1280px line. DESIGN.md §6 + CONTEXT.md updated. tsc/eslint clean. - Design revamp — Newsroom identity + dual theme + live landing walkthrough (2026-07-22). Fixed the "stiff/unprofessional" feel without changing the editorial thesis. (a) Real type system across app + backend cards: Fraunces (
font-display) + Newsreader (font-serif), replacing the Georgia/Times system stack. App vianext/font/google; backend via base64@font-facepartialbackend/app/rendering/templates/_fonts.html(generatorassets/build_fonts_partial.py) included in all 6 templates — verified in rendered PNG. (b) Dual theme (warm-paper light + refined dark): theme-scoped tokens inglobals.css→@theme inline;ThemeProvider+ no-flash<head>script (storage-key inlined, since"use client"value imports become undefined in server components) +ThemeTogglein rail/mobile/masthead; cards stay dark in both. (c) Motion (motion/react) +frontend/lib/motion.tsshared reduced-motion-aware variants. (d) Landing centerpiececomponents/marketing/StudioWalkthrough.tsx(+SyntheticCursor.tsx): live studio sim walking the full status spine (trend → draft → approved → scheduled → published) with scripted cursor/typing, autoplay+loop, "Try it yourself" handoff, reduced-motion static fallback;Landing.tsxre-skinned to a Fraunces masthead. tsc clean,next buildgreen (11 routes), both themes screenshot-verified on/and/login. DESIGN.md §3/§5/§7 + status header, CONTEXT.md updated.- Follow-ups if wanted: newsroom motion/drop-cap pass on the signed-in interior pages (Today/cockpit/board got the Fraunces title swap; deeper column-rule + stagger polish is optional); revisit the strict
react-hooks/set-state-in-effectlint (pre-existing across the repo; the ThemeToggle mounted-guard adds one more — idiomatic, non-blocking).
- Follow-ups if wanted: newsroom motion/drop-cap pass on the signed-in interior pages (Today/cockpit/board got the Fraunces title swap; deeper column-rule + stagger polish is optional); revisit the strict
- Custom-image upload (2026-07-13). Third visual path beside templates + AI/Flux:
POST /posts/{id}/upload-image(multipart) stores the file raw in the same public bucket and inserts aGeneratedAsset(render_kind='upload',template_id=NULL), which then publishes exactly as uploaded — no publisher change (_select_publish_assetspicks the newest asset). Migrationc7f1a4d90e23(enum value + nullabletemplate_id); header-only dimension parser (no Pillow, +4 tests → 383); cockpit Visual panel gains an "Upload custom image" button +apiUploadhelper. Single-image, raw-only this pass (user-confirmed). INSTRUCTIONS.md MIME note updated. Live upload→publish smoke remains user-driven.- Follow-ups if wanted: multi-image upload as a carousel (needs per-slide
slide_indexon uploads + a batch endpoint); an optional brand-wrap frame for uploads (reuse the AI-imageframetemplates).
- Follow-ups if wanted: multi-image upload as a carousel (needs per-slide
- BrandSwitcher honesty fix + workspace rename (2026-08-16).
BrandSwitcher.tsxhardcoded"Nestwhirr"as a static string (a Phase 13 seam built pre-multi-tenancy) — never reconciled with Phase 14's realworkspacestable, so it displayed a brand name with no data behind it.GET /menow returnsworkspace_name(loaded off the caller'sWorkspacerow);BrandSwitcherreads it via the existing shareduseMe()cache instead. NewPATCH /me/workspacelets any user rename their own workspace;components/settings/WorkspaceName.tsx(field + Save, sits above the Connections/Prompts/Templates/Voice sub-nav) + a smallsetCachedMepub/sub inlib/useMe.tsso a rename propagates live to every mounted consumer (rail, mobile bar, admin gate). This is a UI-honesty + single-workspace-rename fix, not multi-brand support — one workspace per user is still the model; true multi-brand needs aworkspace_membersjoin table + active-workspace selection (out of scope, flagged in CONTEXT.md). +4 tests (tests/api/test_me.py) → 421 backend tests pass. tsc clean.- Follow-up if wanted: real multi-workspace membership (a user belonging to N workspaces + an active-workspace switch) — see the 2026-08-16 CONTEXT.md decision entry for the shape.
- Trend inbox — per-workspace consumption + 7-day retention + dismiss-all (2026-08-16). Three complaints (pagination pain clearing the inbox, trends piling up forever, delete-all button) turned out to be one fix: consumption moved off the shared
trends.consumed_atflag onto a new per-workspacetrend_consumptionsjoin table (migration2f27bf499527, no backfill). That unlocked:POST /trends/dismiss-all?status=&source=(bulk-dismiss matching a filter, no id list/pagination — replacesbulk-delete); a purely time-based daily retention sweep (trend_retention_job, wascleanup_consumed_trends_job) using a new single settingTREND_RETENTION_DAYS=7(user-confirmed) that also now drives the RSS ingest cutoff (was hardcoded 90d) and a brand-new HN age filter (HN never had one); single-trend hard-delete moved to admin-only (require_admin) since it now has cross-workspace effect.generator.py's auto-pick + consume-marking are workspace-scoped. Frontend: Inbox "Delete" → "Dismiss" throughout, per-row hard-delete button removed, new "Dismiss all" button. Live-verified against real Supabase (consume isolation, dismiss-all, retention sweep — the sweep smoke actually deleted 580 real stale rows, pool 685→106). 417 backend tests pass, tsc clean, next build green.- Follow-up if wanted: an admin-facing trend-management surface (bulk hard-delete, manual sweep trigger) — not built; the automatic sweep + per-workspace dismiss cover the everyday need today.