Skip to content

fix: Facebook analytics sync#114

Merged
JanSchm merged 6 commits into
brightbeanxyz:mainfrom
heidisoft:codex/fix-facebook-analytics-post-ids
Jul 1, 2026
Merged

fix: Facebook analytics sync#114
JanSchm merged 6 commits into
brightbeanxyz:mainfrom
heidisoft:codex/fix-facebook-analytics-post-ids

Conversation

@heshanlk

Copy link
Copy Markdown
Contributor

What does this PR do?

Prefer per-post summed series when its captured_at is fresher than account-level rows for content-attribution metrics. Track per-metric captured_at (captured_by_metric) and only apply post fallback when it improves freshness or account data is missing. Add logic to avoid using post fallback for metrics that don't support it.

Also add follower-total refresh handling: introduce _FOLLOWER_TOTAL_REFRESH_PLATFORMS and _needs_empty_follower_count_refresh, update _sync_account_metrics to refresh today's follower total when account.follower_count is empty even if daily snapshots exist, and update sync_all_account_analytics to trigger the refresh.

Add tests: service tests for preferring fresher post fallback and preserving account reach, and task test for refreshing empty follower_count when today rows exist.

Why?

How to test

Checklist

  • Tests pass (pytest)
  • Lint passes (ruff check . and ruff format --check .)
  • Documentation updated (if applicable)

heshanlk added 6 commits June 24, 2026 14:24
Prefer per-post summed series when its captured_at is fresher than account-level rows for content-attribution metrics. Track per-metric captured_at (captured_by_metric) and only apply post fallback when it improves freshness or account data is missing. Add logic to avoid using post fallback for metrics that don't support it.

Also add follower-total refresh handling: introduce _FOLLOWER_TOTAL_REFRESH_PLATFORMS and _needs_empty_follower_count_refresh, update _sync_account_metrics to refresh today's follower total when account.follower_count is empty even if daily snapshots exist, and update sync_all_account_analytics to trigger the refresh.

Add tests: service tests for preferring fresher post fallback and preserving account reach, and task test for refreshing empty follower_count when today rows exist.
@heshanlk heshanlk requested a review from JanSchm as a code owner June 28, 2026 02:27

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0381692def

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread apps/analytics/tasks.py
if AccountInsightsSnapshot.objects.filter(social_account=account, date=target).exists():
has_rows_for_day = AccountInsightsSnapshot.objects.filter(social_account=account, date=target).exists()
needs_current_follower_refresh = target == on_date and _needs_empty_follower_count_refresh(account)
if has_rows_for_day and not needs_current_follower_refresh:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid overwriting metrics during follower refresh

When today's snapshot rows already exist but follower_count is empty, this branch now falls through into the normal full metric upsert below. For Meta providers, unavailable/empty insight metrics are normalized into zero-valued entries such as extra["views"], so a follower-count-only refresh can overwrite existing non-zero account rows for today with zeros. In the has_rows_for_day && needs_current_follower_refresh case, only refresh account.follower_count/the follower total instead of writing the rest of metric_values.

Useful? React with 👍 / 👎.

fallback_captured is not None and account_captured is not None and fallback_captured > account_captured
)
if should_use_fallback:
by_metric[m].update(daily)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Replace stale account days when fallback wins

When the per-post fallback wins on freshness, daily is a sparse fallback series where omitted dates mean 0, but update() only overwrites dates present in daily. For a Facebook metric with older account rows across the window and fresher post snapshots only on one day, the chart will mix the fresher post value for that day with stale account values on every omitted day instead of using the selected post-derived series. Replace the metric's day map when fallback wins rather than merging it into the stale account map.

Useful? React with 👍 / 👎.

@JanSchm JanSchm merged commit 599620c into brightbeanxyz:main Jul 1, 2026
5 checks passed
Provibe-dev added a commit to Provibe-dev/brightbean-studio that referenced this pull request Jul 3, 2026
* url extensions

* trailing slashes error

* url config adjustment

* csp upgrade

* intelligence update

* test update

* test updates

* class update

* update connection logic

* readme update

* feat: implement LinkedIn Company Page org selection and publishing (#50)

* feat: implement LinkedIn Company Page org selection and publishing

The LinkedIn Company provider previously connected using the
authenticated user's personal profile instead of the organization,
resulting in posts publishing to the wrong account.

- Add get_user_pages() to LinkedInCompanyProvider that lists orgs the
  user administers via /v2/organizationalEntityAcls, fetching org name,
  vanity name, and logo via projection
- Add linkedin_company to the page selection flow in oauth_callback so
  users pick which company page to connect (reuses existing Facebook
  multi-page selector UI)
- Inject urn:li:organization:{id} as the post author in the publish
  engine so posts go to the company page, not the personal profile

Fixes #48

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* polish org-selection flow and add coverage

Follow-up to the initial implementation:
- Drop unused AccountProfile import from providers/linkedin_company.py
- Branch the "no pages found" warning on platform so LinkedIn admins
  see LinkedIn-specific guidance instead of Facebook copy ("Facebook
  Settings -> Business Integrations") that doesn't apply to them
- Add 5 tests for LinkedInCompanyProvider.get_user_pages covering
  the logoV2 projection happy path, missing logo, empty elements,
  empty acl list, and the URN-missing id fallback
- Add 3 tests for the publisher engine's linkedin_company author
  injection: happy-path organization URN, preservation of caller-set
  author in platform_extra, and scoping to linkedin_company only

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Jan Schmitz <jan.schmitz@whu.edu>

* Show saved templates in Use Template picker (#46) (#52)

* fix(composer): saved templates not shown in Use Template picker (#46)

The picker modal sits inside the composer-form which sets
hx-swap="none". HTMX inherits hx-swap down the tree, so the picker
fetched the partial successfully but never swapped it into the DOM,
leaving the placeholder visible. The previous hx-trigger="intersect once"
also fires unreliably for an element starting at display:none.

Switch to an Alpine-driven load-picker event fired in the next tick after
the modal opens, refresh on existing templateSaved/templateChanged
broadcasts, and pin hx-swap="innerHTML" on the picker to override the
inherited hx-swap="none". Add view-level tests covering workspace
scoping and the empty state.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* style: apply ruff format to test_template_picker

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* style: apply ruff format to providers/linkedin_company

This file was on main with formatting that ruff format --check
flags as needing reformat, so every PR currently fails the lint
job. The reformat is auto-generated and unrelated to the composer
template-picker fix in this PR; pulled in here to unblock CI.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* Agent API: REST + MCP surface for agentic post scheduling (#53)

* Add Agent API: REST + MCP surface for agentic post scheduling

Introduces a workspace-scoped programmatic API so external AI agents
(Zapier-style bots, custom LangChain workers, Claude/Cursor via MCP)
can draft, schedule, update, and cancel posts on the user's connected
social accounts without screen-scraping the HTMX UI.

* CTA style update

* UI improvement

* permission refinments

* Update views.py

* key display update

* ruff updates

* agent media interface

* CI error fixes

* organization_id state

* test calls for direct Instagram connection - account verification

* Analytics Feature

* tiktok provider update

* CI error fixes

* CI error fixes

* linkedin provider video updates

* ruff check

* CSP error fix

* Platform icon UI update

* UI layout improvement

* UI updates

* CSP config change

* threads icon update

* exclude personal linkedin accounts from analytics

* ruff CI check

* Show media thumbnails in analytics

* media asset in analytics page

* media asset on analytics page

* Linkedin personal account - no analytics page

* copy update

* Analytics extension for REST and MCP

* analytics general update

* Update base.html

* Analytics update

* readme update

* ruff fixes

* csp swagger update

* ruff error fix

* ruff error fix

* youtube analytics

* tiktok requirements update

* readme update

* Add admin UI for platform credentials with env-dominant resolution (#60)

The README documented a "Settings → Platform Credentials" admin UI, but it never
worked: the page was a "coming soon" placeholder, the Django admin forced the
credentials field read-only, and nothing ever set the `is_configured` flag the
runtime gates on — so the DB-credential path was dead and credentials could only
come from `.env`.

This makes the existing Django admin usable and wires per-org DB credentials into
the runtime, while keeping `.env` authoritative.

- PlatformCredential.save() derives `is_configured` from the credential values via
  a per-platform required-keys map (TikTok `client_key`, Meta/Pinterest `app_id`
  aliases, etc.), so a row only activates when it is actually usable.
- New PlatformCredentialAdminForm: a real `forms.JSONField` (avoids a Python-repr
  corruption trap on the encrypted field), validates required keys, limits the
  platform choices to credential-bearing platforms. Admin restricted to
  superusers (editing reveals decrypted secrets).
- resolve_platform_credentials(platform, org_id): one env-dominant resolver (env
  wins when complete, else the org's admin row). All five credential-resolution
  call sites route through it (they were DB-first before).
- Webhooks: verify the Meta HMAC per owning-org secret (resolve_app_secret) so
  one org's secret cannot forge events into another org's accounts.
- Remove the dormant /credentials/ placeholder (view, url, template, include).
- Docs: README + .env.example reflect the real admin path, the env-dominant
  precedence rule, and how to create a superuser.

Tests: per-platform env backwards-compat, cross-tenant webhook isolation, admin
superuser gating, resolver precedence. Full suite green; ruff clean; no migration.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* Add OAuth 2.1 to the MCP server so Claude Desktop can connect (#61)

The /api/v1/mcp endpoint previously accepted only a static bb_studio_ API
key and returned a bare 401. Claude Code can inject a static header, so it
connected fine; Claude Desktop's remote connector needs an OAuth login flow
(it has no static-token field), so it could not. Both clients already speak
Streamable HTTP — the gap was authentication, not transport.

This adds an OAuth 2.1 Authorization Server (django-oauth-toolkit) and makes
the MCP endpoint accept OAuth tokens alongside the existing API keys:

- apps/oauth_server: Dynamic Client Registration (RFC 7591), RFC 8414/9728
  discovery metadata, and an S256-only PKCE validator.
- McpAuth (apps/api/auth.py): bb_studio_ tokens take the existing key path
  unchanged; any other bearer resolves a DOT access token, maps the user to
  their active workspace, and runs the handlers via an OAuthMcpActor shim
  carrying that user's own permissions.
- A 401 + WWW-Authenticate challenge on the MCP path to start the OAuth flow.
- ApiKeyAuditLog gains a nullable api_key plus actor_user/actor_label so
  OAuth calls are audited against the user.

Any Studio user can connect; they act only with their own workspace
permissions (read-only roles get read tools; writes require the matching
permission). Mirrors social-intelligence-app's OAuth server.

* Fix OAuth consent CSP so Claude Desktop can complete the connection (#62)

The MCP OAuth consent screen silently failed in Claude's browser:
approving it 302-redirects to https://claude.ai/api/mcp/auth_callback,
but Chromium enforces form-action across the redirect chain and
claude.ai wasn't allowlisted, so the authorization code never reached
Claude. A second (cosmetic) console error came from django-oauth-toolkit's
stock template loading Bootstrap from the dead netdna.bootstrapcdn.com,
which style-src blocks.

- Scope form-action to allow claude.ai/claude.com on the authorize view
  only (csp_update appends to the global policy), not site-wide.
- Override the oauth2_provider templates with a self-contained,
  BrightBean-branded consent page that drops the external CDN. All DOT
  form mechanics (csrf token, hidden fields, name="allow") are preserved.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* mcp oauth

* Update auth.py

* Serve POST /api/v1/mcp without trailing-slash redirect (#64)

Claude Desktop POSTs to /api/v1/mcp — the URL the RFC 9728 metadata and
README advertise — but the ninja route only existed at /api/v1/mcp/, so
CommonMiddleware's APPEND_SLASH answered with a 301. Clients follow a
301 by re-issuing the request as GET, which hit the POST-only route and
died with 405 before auth ever ran: the unauthenticated handshake never
received the 401 + WWW-Authenticate challenge that starts the OAuth
flow, and Desktop surfaced "Authorization with the MCP server failed".

Register the endpoint at "" alongside "/" so both URL forms dispatch
directly (the no-slash alias is hidden from the OpenAPI schema). GETs
on either form now return a spec-compliant 405 with no redirect.
Regression tests cover the exact production failure and red-bar
without the alias.

* CI: bump actions to Node 24 releases; pull postgres via ECR mirror (#65)

GitHub forces Node 24 for actions on June 16, 2026 (Node 20 removed
Sept 16). Bump all SHA-pinned actions to their latest Node 24 releases:

- actions/checkout v4.2.2 -> v6.0.3
- actions/setup-python v5.3.0 -> v6.2.0
- docker/setup-buildx-action v3.7.1 -> v4.1.0
- docker/build-push-action v6.10.0 -> v7.2.0

Also pull the pytest job's postgres:16-alpine service image through
AWS's public mirror of Docker Hub official images: an anonymous Docker
Hub pull timeout took down main CI on 2026-06-10 (run 27262568530).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* Fix TikTok unaudited-403 publishing and scoped-composer sibling deletion (#67)

* Fix TikTok unaudited-403 handling and scoped-composer sibling deletion

Two production bugs from a combined YouTube Shorts + TikTok publish:

TikTok publishing (403 unaudited_client_can_only_post_to_private_accounts):
- Query creator_info before publishing and validate the requested privacy
  level against the creator's allowed options, failing fast with an
  actionable message explaining TikTok's content-posting audit status
- Classify permanent TikTok error codes as non-retryable via a new
  `retryable` flag on provider exceptions, honored by the publish engine
  (no more burning the 3-retry backoff on app-level rejections)
- Add an audit-compliant TikTok settings panel to the composer: privacy
  selector populated live from creator_info with no pre-selected default,
  comment/duet/stitch toggles, commercial-content disclosure, AI-generated
  content label (is_aigc), and the Music Usage Confirmation declaration
- Surface publish_error in the calendar Sent tab, a composer banner, and
  the API schema (it was persisted but rendered nowhere)

Scoped composer (?account=) deleting published siblings:
- Opening a post scoped to one account rendered only that account, so
  save/autosave deleted every sibling PlatformPost - including published
  ones, cascading away their PublishLog history
- Thread the scope through POST via a hidden account_scope input; saves
  now only manage rows inside the scope
- Never delete published/publishing rows on deselection
  (PlatformPost.PROTECTED_STATUSES); scoped saves no longer reschedule or
  transition siblings

Hardening and cleanup from review:
- Validate ?account= up front (garbage renders unscoped, not a 500 or a
  poisoned scope); reject malformed account_scope POSTs with 400
- Preserve a saved TikTok privacy level when an empty value is submitted
- Drop non-UUID selected_accounts entries instead of 500ing
- Extract SocialAccount.refresh_oauth_token() (was triplicated),
  _init_video_publish() in the TikTok provider, and delegate the engine's
  max-retries arm to _fail_permanently(); compose() now fetches platform
  posts once instead of four times

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Annotate TikTok post_info dict for mypy

The optional post_info fields are booleans while title/privacy_level are
strings; mypy inferred dict[str, str] from the literal and rejected the
bool assignment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Match TikTok panel to TikTok's native post UI; bean-green controls

- "Who can see this post" fallback options are Everyone / Friends /
  Only you (FOLLOWER_OF_CREATOR still labeled when creator_info returns
  it for private accounts)
- "Allow users to" now offers Comment and "Reuse of content" — one
  switch covering Duet, Stitch, stickers and story reuse, mapped to the
  API's disable_duet + disable_stitch flags
- "Disclose post content" and "AI-generated content" are toggle
  switches instead of checkboxes
- New bean-green brand tokens (sampled from the logo) drive checkbox
  accent-color and the toggle switches; without the Tailwind forms
  plugin the old text-* utilities did nothing and controls rendered in
  the browser-default blue

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Default TikTok privacy to Everyone; inset the select chevron

- "Who can see this post" pre-selects Everyone (PUBLIC_TO_EVERYONE);
  when creator_info disallows the current choice the select falls back
  to the first allowed option (SELF_ONLY for unaudited apps) instead of
  the empty placeholder
- New .bb-select class replaces the native select arrow with an SVG
  chevron inset 12px from the right edge (the native one hugs the
  border)

* Unsplash integration in composer and thumbnail selection

* Add MCP presigned direct-to-R2 upload for large media (#68)

Large media (videos) can't ride a base64 JSON-RPC envelope, so MCP
upload_media is capped at 1 MB and agents fell back to a REST shell key.
That lands the media in the key's workspace while the OAuth-MCP
connection resolves a different workspace, so create_draft's
workspace-scoped media lookup couldn't find it ("not found in workspace").

Add a presigned direct-to-R2 flow that runs entirely over the OAuth
connection, so upload and create resolve the same workspace:

* request_media_upload presigns an S3/R2 POST for a server-chosen key
  (UUID basename, no agent-controlled path) and records a PendingUpload
  row binding the key to the caller's workspace + user.
* the agent uploads the bytes straight to storage (no BrightBean creds).
* finalize_media_upload validates the stored object server-side — size
  via HEAD, real MIME via a range-GET magic-byte sniff through the shared
  validate_file chokepoint, plus the org storage quota — then registers a
  MediaAsset pointing at the existing key without re-uploading, and
  queues processing. Remote I/O runs outside the row lock; the
  select_for_update spans only the idempotent create.

Also normalize media-id matching in create_post to UUID so MCP callers
(JSON string ids, any case) match the same assets REST callers do — an
independent cause of the original "not found in workspace" error.

A post_migrate-registered sweep reaps expired, never-finalized uploads
and their orphaned objects.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* Run scheduled background work on the worker across all deploy targets (#69)

* Run scheduled background work on the worker across all deploy targets

Wire five recurring jobs onto the existing django-background-tasks worker via
idempotent post_migrate registration, so they run wherever the process_tasks
worker runs (VPS, Heroku, Render, Railway) with no external scheduler:

- inbox sync + SLA checks (every 5 min)
- notification delivery retries (every 1 min)
- approval reminders / escalation (hourly)
- orphaned-media cleanup (daily)
- expired-session cleanup (daily)

The orphaned-media + session cleanups previously ran only via a VPS-only
docker-compose "maintenance" loop, now removed in favor of worker tasks so they
run on every target.

Registration is centralized in apps/common/background.register_recurring_task,
which logs unexpected failures (at exception) instead of swallowing them, while
still tolerating a not-yet-migrated database on a fresh deploy.

First-run guards: inbox suppresses notifications for the historical backlog
pulled on an account's first sync but still alerts for genuinely recent messages
(so a long-quiet account's first real message is not silenced); the retry sweep
is capped per run so a backlog drains gradually; backfill_inbox seeds silently.

Adds tests for the inbox first-sync guard and the retry cap. Documents that the
Railway one-click template provisions web (migrate on startup) + worker +
Postgres, so scheduling works out of the box.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Fix pre-existing repo-wide ruff and mypy failures

Unrelated to the background-task work, but fixed to make the repo-wide checks
green (ruff check ., ruff format --check ., mypy apps/):

- Exclude generated migrations from ruff (Django orders their imports its own way).
- mypy: assert-narrow a nullable media_asset FK in mcp/handlers and a UUID|None
  dict key in composer/services (both already guaranteed by preceding guards).
- mypy: ignore the S3-only _normalize_name access in media_library/storage.
- ruff format: wrap a long signature in composer/services; reformat an mcp test.

* Fix posting-time delete 404 and harden posting-slot endpoints (#70)

* Fix posting-time delete 404 and harden posting-slot endpoints

The posting-slots grid never auto-refreshed after a slot change: its htmx
trigger filter read `accountId` instead of `event.detail.accountId` (htmx 2.x),
so it never matched. A deleted slot lingered on screen and a second click POSTed
to the already-deleted slot -> 404.

- Fix the trigger filter to slotsUpdated[detail.accountId==...] so the grid
  refreshes after delete/add/toggle/edit.
- Make delete/update idempotent: an already-gone slot returns the grid-refresh
  trigger instead of 404 (stale-page / concurrent-delete / double-click self-heal).
- Gate all posting-slot endpoints on manage_social_accounts; they previously
  enforced only workspace membership, so roles lacking that permission could
  mutate slots via direct POST.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Apply ruff format to tests.py

CI runs `ruff format --check .`; an over-wrapped self.client.post() call in a
new test fit on one line under the configured line length. No logic change.

* invite member hardening

* Fix inbox banner duplication & members dropdown clipping; add drafts delete menu (#71)

Three independent Publish/Studio UI fixes:

Inbox: the feed view passed the InboxMessage queryset under the context key
"messages", which shadows Django's messages-framework variable that base.html
renders as flash banners (InboxMessage.__str__ -> "Comment from @user"). The
feed items were therefore painted twice — once correctly in the list, once as
stray banners at the top. Renamed the key to "inbox_messages" in the feed and
bulk-action views and in _message_list.html.

Members: the last row's kebab dropdown was clipped by the members card's
overflow:hidden. Converted it to the existing x-teleport="body" + fixed
positioning pattern so it escapes the overflow container. Because htmx does
not re-scan Alpine-teleported nodes, added x-init="htmx.process($el)" so the
role/remove/manage-workspaces controls stay wired on htmx-swapped rows.

Drafts: added a per-row kebab menu (Edit + Delete) with a confirmation modal
that deletes the draft via the existing composer:post_delete endpoint and
removes the row through the existing post-deleted -> reloadTab() flow (htmx
partial swap, no full reload). Extracted the queue tab's kebab and modal into
shared partials (_post_actions_menu.html, _delete_post_modal.html) reused by
both tabs, and drove the teleported menu's visibility through a single :style
binding to avoid an x-show/:style display:none conflict.

All changes use only Alpine + htmx (no inline handlers/scripts) and were
verified in a local browser; no CSP violations.

* Resolve queue and recurrence schedule times in the workspace timezone (#72)

PostingSlot times and recurring-post times are wall-clock times in the
workspace timezone (which falls back to the org's default_timezone), but the
scheduling code resolved them in UTC, so any org not on UTC published at the
wrong local time.

- _next_slot_datetimes stamped the naive slot time with whatever tzinfo the
  caller's baseline carried; assign_queue_slots passed timezone.now() (UTC),
  so a "09:00" slot was scheduled at 09:00 UTC instead of 09:00 local. This is
  the live path for Add-to-queue and queue reordering. It now derives the zone
  from the account's workspace and walks/zones dates in that zone.
- generate_recurring_posts cloned recurrences from the source's UTC
  time-of-day and bounded the lookahead horizon by the UTC date, so a series
  drifted by the DST offset across a DST boundary and the 90-day horizon was
  off by one local day for non-UTC workspaces. It now does all date/time math
  (base time, dedup, horizon, skip check) in the post's workspace zone. (The
  task is not yet wired to a scheduler, so this path was latent.)

Adds regression tests for workspace-tz queue-slot resolution, DST-preserving
recurrence, and the workspace-local lookahead horizon.

* editing expansion

* Use POST for composer live preview (#73)

* fix: Fix member workspace modal target (#74)

* Use POST for composer live preview

* Fix member workspace modal target

* Fix CI lint and mypy issues

* Reconcile Instagram account picker + analytics metrics (supersedes #75, #76) (#77)

* Reconcile Instagram account picker + analytics metrics

Combines the correct halves of #75 and #76. The two PRs forked from a
shared commit and cannot both be merged as-is: #75 carried the account
picker plus token handling but a non-working first-cut analytics fix,
while #76 carried the correct analytics fix but lacked the token handling.

- Account picker (from #75): InstagramProvider.get_user_pages() surfaces
  Instagram Business accounts from managed Facebook Pages; the OAuth
  callback routes Instagram through the existing account-selection flow;
  select_account falls back to the user token for Instagram and skips
  pages with no usable token; ig_user_id is plumbed through publish,
  health-check, and analytics credential resolution.
- Analytics metrics (from #76): account insights drops the rejected
  `impressions` metric and fetches `views` via a separate
  metric_type=total_value call (both Instagram providers), preserving
  `views` in AccountMetrics.extra for the existing Instagram catalog.

#75's first-cut analytics (requesting `views` without
metric_type=total_value, parsed from the wrong response shape) is
dropped in favor of #76's correct split request.

Supersedes #75 and #76.

Co-Authored-By: Heshan Wanigasooriya <heshanmw@gmail.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Fix pre-existing main CI breakage (ruff format + mypy in api_keys)

main @ c0432c9 left apps/api_keys red: `ruff format --check` would
reformat tests/test_views.py, and mypy flagged `datetime | None` at the
is_naive/make_aware calls in views.py (the `_parse_expires_at` refactor).

Mirror PR #74's "Fix CI lint and mypy issues" fix here (identical edits)
so this branch is green standalone without depending on #74's merge order.

* feat: proposed publishing datetime for drafts (#81)

* feat: proposed publishing datetime for drafts

Add an optional, draft-stage "proposed publishing datetime"
(`Post.proposed_publish_at`) — a suggested time a planner or agent can
record on a draft without committing it to the publisher's poll loop or
the approval gate.

What changed
- Model + migration 0017: nullable `proposed_publish_at` on Post.
- Composer: the existing Schedule Post panel doubles as the proposed-time
  editor. Saving a draft (or submitting for approval) captures the panel
  value as the proposal; it never schedules. The panel pre-fills from the
  proposal on edit, and the primary action stays "Save Draft" rather than
  arming a real schedule.
- A committed schedule always supersedes the proposal: cleared centrally in
  `sync_post_scheduled_at`, so every path (create_post, the REST `/schedule`
  route, the MCP `schedule_draft` tool, and the PATCH re-time) drops it — the
  two never coexist.
- Display: shown on every drafts/approval surface (standalone Drafts page,
  approvals queue, Publish drafts/approvals tabs, org queue, client portal),
  rendered in the workspace timezone via `{% timezone %}`. Draft lists read
  "Proposed Publishing <datetime>".
- REST: `proposed_publish_at` on PostResponse (returned everywhere), settable
  on create/PATCH. MCP: optional arg on `create_draft`, returned by every tool.
- Hardening from self-review: validate the user-controlled `?tz=` param so a
  bad/empty value can't 500 the publish tabs; capture guard also checks
  `post.scheduled_at`; submit/resubmit won't wipe an API-set proposal on a
  blank panel; MCP datetime parsing makes tz-less input UTC-aware.

Tests: service, composer view, REST, MCP, and calendar-tz regression tests;
324 passing across the touched apps. Docs updated (README, feature spec).

* fix: clear proposed_publish_at on the composer per-account chip schedule

The composer's per-account chip endpoint (`transition_platform_post` view)
mutates a single PlatformPost directly via `transition_to()`/`save()` and
never reached `sync_post_scheduled_at`, so toggling a draft that carries a
`proposed_publish_at` to `scheduled` left the post with both a committed
status and a stale proposal — breaking the "they never coexist" invariant
the rest of the feature upholds. (Flagged by Codex review.)

Fix, keyed on status so it's robust:
- `sync_post_scheduled_at` now drops `proposed_publish_at` when any child is
  in a committed status (scheduled/publishing/published), not just when a
  `scheduled_at` is present. The chip menu sets `scheduled` WITHOUT a
  `scheduled_at`, so the previous scheduled_at-keyed clear missed it. This
  mirrors `_capture_proposed_publish_at`'s guard.
- The chip endpoint now calls `sync_post_scheduled_at(pp.post)` after the
  transition, reconciling both the `Post.scheduled_at` aggregate and the
  proposal the same way the service-layer transition does.

Adds regression tests (service-level status-only clear + the chip endpoint).

* fix: keep chip schedule-clear off the publisher's scheduled_at fallback (#82)

The previous fix routed the composer per-account chip endpoint through
`sync_post_scheduled_at` to clear the proposal. But the publisher selects due
posts via `Coalesce(scheduled_at, post__scheduled_at)`, so a PlatformPost with
a NULL `scheduled_at` depends on `Post.scheduled_at`. Recomputing that
aggregate from the chip endpoint (which commits a child to `scheduled` WITHOUT
a `scheduled_at`) could clear `Post.scheduled_at` and strand the post — it
would never publish.

Make the proposal-clear side-effect-free instead:
- `sync_post_scheduled_at` keeps its original `scheduled_at`-based logic
  (identical `Post.scheduled_at` behaviour to before the feature); only the
  proposal clear in the has-scheduled-children branch remains.
- The chip endpoint clears `proposed_publish_at` directly and never touches
  `Post.scheduled_at`, so the publisher's Coalesce fallback is undisturbed.

Adds a publisher regression test asserting a scheduled child with a NULL
`scheduled_at` is still returned by `PublishEngine._get_due_platform_posts()`
via the Post-level fallback. Full suite incl. apps/publisher: 343 passing.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat: expose internal_notes on posts via REST API and MCP tools (#86)

Surface the team-only `internal_notes` field (already on the Post model
and edited in the composer) on the Agent API and MCP so integrations can
attach and read internal notes when creating or editing posts, reusing
the existing endpoints/tools. No migration — the column already exists.

- Thread internal_notes through the shared create_post() service.
- REST: accept on POST / PATCH; return on PostResponse.
- MCP: accept on create_draft and schedule_post; get_post returns it.

Gate visibility on the create_posts permission in the shared
PostResponse serializer (redacted to "" otherwise), mirroring the
composer hiding internal notes from client/viewer roles. Reads
(GET /posts/{id}, MCP get_post) aren't otherwise permission-gated, so
without this a read-capable client/viewer OAuth caller could see team
notes. from_post() defaults to redacted so new call sites fail closed.

* feat: Adds channel posting slots to the calendar day and week timeline views (#80)

* Add channel slots to calendar timeline

* Format calendar slot view data

* Refine calendar slot indicators

* Refine calendar slot indicators

* Address calendar slot review edge cases

* fix: honor workspace timezone for calendar slot/CTA links and gate slots precisely (#87)

* fix: honor workspace timezone for calendar "+" links and gate slots precisely

Generic hourly "+" buttons in the day/week grids encoded the display-tz
hour, but the composer interprets scheduled_time in the workspace timezone,
so they scheduled at the wrong instant whenever the calendar display
timezone differed from the workspace one. They now emit the workspace-tz
wall time of the cell (via _cell_compose_params), matching the existing
channel-slot "+" links.

Channel-slot badges now gate the "+"/"Open" state on a precise per-slot
is_past (slot instant vs now) instead of hour granularity, so a slot
earlier in the current hour no longer offers an actionable "+" that the
composer would immediately reject as a past time.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: convert drag-and-drop reschedule target to workspace timezone

Codex review follow-up. dropOnSlot passed the display-tz date/hour to
reschedule_post, which interprets the naive value in the workspace
timezone, so dragging a chip under a non-workspace display timezone
rescheduled to the wrong instant -- the same class of bug already fixed
for the "+" create links.

The day/week grids now pass the cell's workspace-tz compose_date/
compose_time, and dropOnSlot takes an "HH:MM" string instead of an
integer hour. The month grid passes a zero-padded "09:00" so every
caller agrees on the format (its behavior is unchanged).

* fix: support Facebook multi-photo posts (#83)

* Support Facebook multi-photo posts

* Fix Facebook first comment target

* Revert "Fix Facebook first comment target"

This reverts commit 98f02c83fa663ee43ca2521253689628b3fadb36.

* fix(facebook): harden multi-photo publishing (#89)

* fix(facebook): harden multi-photo publishing

Follow-up to #83. The multi-photo path was correct for the happy path but
not defensive about edge cases. This adds:

- Mixed-media guard: reject posts containing a video URL before staging
  (Facebook's /photos edge cannot stage video), with a clear error instead
  of a cryptic Graph failure. Mirrors the per-item type checks in the
  Instagram/Threads carousel providers.
- Photo-count guard: reject more than FACEBOOK_MAX_ATTACHED_MEDIA (10)
  before any network call.
- Orphan cleanup: best-effort delete of already-staged unpublished photos
  if staging or the feed call fails, so partial failures and publisher
  retries don't accumulate dangling media on the page.

Both guards run before any photo is staged. Adds a single-photo regression
test plus tests for each new path; full providers suite (151) passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* style: apply ruff format to facebook provider + tests

CI runs `ruff format --check .` in addition to `ruff check`; reflow the new
lines to satisfy the formatter. No behavior change.

* fix(tiktok): meet Content Posting API UX requirements for audit (#94)

TikTok rejected the Content Posting API review for not following the
"Required UX Implementation" guidelines. Bring the composer's TikTok
panel and the publish path into compliance:

- Privacy: no default value; the creator-info effect clears an invalid
  saved value instead of auto-selecting one.
- Interaction settings: split the combined "Reuse of content" toggle into
  separate Comment / Duet / Stitch checkboxes, each unchecked by default
  and greyed out independently per creator_info.
- Commercial content disclosure: show TikTok's exact label prompt on
  selection ("Promotional content" / "Paid partnership"; both ->
  "Paid partnership"), require a sub-option when disclosure is on, and
  keep branded content off SELF_ONLY (disabled option + tooltip; clears a
  SELF_ONLY choice).
- Add the post-publish processing-delay notice.
- Enforce max_video_post_duration_sec: PublishContent carries the primary
  video duration (from MediaAsset.duration via the publisher engine) and
  the provider blocks an over-limit video before init.

Also fix a pre-existing bug where generate_recurring_posts dropped
platform_extra when cloning PlatformPosts, so recurring TikTok posts lost
their privacy/interaction/disclosure settings.

Tests: split-interaction round-trips, composer default-state guards, the
duration guard, and recurrence platform_extra preservation.

* fix(analytics): stop re-syncing YouTube accounts flagged for analytics reconnect (#95)

The hourly sync_all_account_analytics cron calls the YouTube Analytics API
for every connected account. Accounts whose stored OAuth token predates the
analytics rollout lack the yt-analytics.readonly scope, so the call fails
403. The failure is already handled (logged, account flagged
analytics_needs_reconnect, "Reconnect for analytics" CTA shown), but a
failed account never writes a snapshot row, so the cron retried it every
hour indefinitely — one failed API call and one WARNING per flagged account
per hour.

Skip the account-metrics call for accounts already flagged
analytics_needs_reconnect. Reconnecting clears the flag and runs a one-shot
backfill, so the cron resumes on its own. The per-post Data-API loop is
unaffected and keeps syncing (it uses the publish/read scopes the account
already has).

Adds the first test module for apps/analytics covering the skip behavior.

* fix: resolve per-account provider credentials at all get_provider call sites (#100)

* fix(inbox): resolve per-account credentials so Mastodon sync builds valid URLs

The inbox app instantiated providers via get_provider(account.platform) with no
credentials, so the federated MastodonProvider received an empty instance_url and
built scheme-less URLs (e.g. "/api/v1/notifications") that httpx rejects with
UnsupportedProtocol. Reuse _resolve_publish_credentials() — the resolver every
other get_provider caller already uses — at the three inbox call sites (sync,
reply, backfill) so the account's instance_url reaches the provider.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(social-accounts): resolve health-check credentials via the shared resolver

check_social_account_health kept its own inline copy of per-account credential
resolution. It injected the Mastodon instance_url only inside the
MastodonAppRegistration lookup (dropping it entirely when no registration row
existed) and applied no is_safe_url SSRF check. Replace the inline block with a
call to _resolve_publish_credentials() so every get_provider call site resolves
credentials identically: instance_url is set first (registration only backfills
client_id/secret), the SSRF gate applies, and Bluesky pds_url / Instagram
ig_user_id are handled too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(publisher): SSRF-check the Bluesky PDS URL in credential resolver

The Bluesky branch of _resolve_publish_credentials set the outbound pds_url host
directly from the user-controlled account.instance_url with no validation, while
the Mastodon branch already gated instance_url through is_safe_url. Apply the same
is_safe_url gate to pds_url (warn and drop on failure) so a PDS URL resolving to a
private/reserved/loopback address can't drive outbound requests. This is the
central chokepoint, so it protects the publish engine, inbox sync/reply/backfill,
and the health check uniformly. Matches the codebase's established SSRF approach
(is_safe_url + OS-resolver-cache reuse); resolve_public_ip pinning is intentionally
not used, consistent with the webhook dispatcher's documented choice to avoid an
httpx-transport dependency.

* fix(calendar): don't re-slot already-published posts when a queue changes (#96)

* fix(calendar): don't re-slot already-published posts when a queue changes

assign_queue_slots() recomputed slot times for every entry in a queue
whenever a post was added (add_to_queue) or the queue was reordered
(reorder_queue) — including posts that had already been published.
Published QueueEntry rows are never removed, so each add/reorder
reassigned those old entries to upcoming posting slots, overwriting their
scheduled_at with a future time. The calendar places chips by
scheduled_at, so the affected posts then rendered as green "published"
chips up to a week in the future (reported on a LinkedIn account).

Prevention:
- assign_queue_slots now skips entries whose PlatformPost is in
  PROTECTED_STATUSES (published/publishing): they neither move nor consume
  a slot, so live entries still flow into the soonest open times. Mirrors
  the existing guards in the composer schedule propagation and
  reschedule_post.

Repair (already-corrupted rows):
- repair_future_published_scheduled_at() + a repair_published_scheduled_at
  management command (--dry-run, --workspace). A published post always has
  scheduled_at <= published_at, so scheduled_at > published_at is an
  unambiguous signature of this bug; those rows are snapped back to their
  true published_at and the parent Post aggregate re-synced. Idempotent.

Tests: guard on both queue paths, repair dry-run/apply/idempotency/
workspace-scope, and a management-command smoke test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(calendar): reset stale queue slots and protect fallback siblings on repair

Two review follow-ups to the queue re-slot fix, both in
repair_future_published_scheduled_at:

- QueueEntry cleanup (Codex review): the old re-slot bug wrote the same
  future timestamp to QueueEntry.assigned_slot_datetime as well as
  PlatformPost.scheduled_at. queue_detail renders that field, and the new
  published-status guard means assign_queue_slots never recomputes a
  published entry — so the queue UI would keep showing the post in the
  future. The repair now snaps the matching QueueEntry back to published_at
  too (same `> published_at` signature; surfaced via queue_entry_count and
  the command output).

- Fallback-scheduled siblings: snapping a published child back to its past
  published_at lowers the parent Post.scheduled_at aggregate. A SCHEDULED
  sibling with scheduled_at=NULL resolves its due time via the publisher's
  Coalesce(scheduled_at, post__scheduled_at) fallback, so the backward
  parent move would make it instantly due and publish early. The repair now
  pins such siblings to their current effective time inside a transaction
  before lowering the parent.

Tests cover both edge cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(calendar): stable slot-occupancy queue ops (gaps + local moves)

Replace the recompute-everything-from-now queue model with local,
gap-preserving operations keyed off PlatformPost.scheduled_at (the
publisher's source of truth), so an entry's slot stays stable until it is
explicitly moved.

- add_post_next_available: fill the first open gap (upsert-aware → also the
  Edit · Next Available flow).
- prioritize: take slot[0]; if taken, ladder each occupied entry [k]→[k+1]
  preserving the gap pattern.
- remove_from_queue: drop the entry, draft the child (nulls scheduled_at via
  the transition so it can't become instantly due), leave a gap.
- reslot_to_next_available; reorder_queue now permutes occupied datetimes.
- add_to_queue kept as a thin back-compat dispatcher.
- QueueFullError when no slot is open within the 60-day horizon.

Tests: first-gap, occupied-unchanged, prioritize ladder + gap, [0]-free
no-shift, remove gap+draft+not-due, reslot, upsert, queue-full. Existing
QueueSlot/Repair tests stay green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(publisher): drop QueueEntry when a post publishes

On a successful publish, delete the QueueEntry holding that channel's slot so
the queue shows only upcoming posts and the slot frees as a gap. Keeps the
PlatformPost + published_at. Structurally prevents a published row from being
re-slotted (the lingering-entry bug class), making the assign-time guard +
repair command belt-and-suspenders.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(calendar): single-entry queue remove/reslot endpoints + UI

- queue_entry_remove / queue_entry_reslot views + URLs (workspace-scoped,
  @require_POST, idempotent self-heal, HX-Trigger queueReordered).
- queue_detail now orders entries by assigned_slot_datetime (positions can be
  sparse after gap-fills) and shows a per-entry remove button (leaves a gap).
- Tests: remove deletes+drafts, idempotent-when-gone, foreign-workspace no-op,
  reslot-to-next-gap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(composer): gap-aware floor queueing, QueueFull handling, draft unqueues

- _reassign_queue_slots_from_floor now picks the first FREE slot on/after the
  floor (no double-booking) and keeps the QueueEntry mirror in sync with the
  PlatformPost.
- add_to_queue / add_to_queue_priority actions surface QueueFullError as a 400
  with a clear message instead of a 500.
- transition_platform_post(→draft) deletes the matching QueueEntry, so cancel /
  unschedule paths free the slot instead of orphaning a stale row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(composer): status guard rails + Clone/Repost

- clone_post(post) service: duplicate a post into a fresh DRAFT (caption, title,
  per-platform overrides + platform_extra deepcopy, media), authored by the
  caller. Factored to be reusable (mirrors the recurrence clone, but draft).
- /compose/<id>/clone/ endpoint (create_posts-gated) → opens the copy.
- compose.html guard rails: SCHEDULED edit warning banner; PUBLISHED/PUBLISHING/
  PARTIALLY_PUBLISHED read-only banner with a Clone CTA; FAILED block gains a
  "Clone to retry" action.
- Tests: clone service (draft copy, deepcopy isolation), endpoint redirect +
  permission denial.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(calendar): queue_detail render + chronological order smoke

Covers the view ordering change (by assigned_slot_datetime) and the new
per-entry remove button.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(queue): atomic multi-queue add + collision/horizon-safe prioritize

Addresses two Codex review findings:

- Multi-queue Add to Queue is now one transaction (both composer branches): if
  a later queue is full, the earlier queues' slot writes roll back instead of
  leaving a child half-queued (QueueEntry + future scheduled_at on a draft).
- prioritize's ladder no longer blindly writes candidates[i+1]: each mover
  lands on the next candidate slot that is neither held by an immovable post
  (foreign/manual/out-of-horizon) nor already claimed by another mover, and
  raises QueueFullError instead of IndexError when the horizon is exhausted.

Tests: ladder skips a foreign-occupied slot (no double-book), ladder raises
QueueFullError on horizon exhaustion, composer rolls back partial multi-queue
writes on QueueFullError.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(queue): address code-review findings (divergence, double-publish, guards)

Verified-and-fixed findings from the max-effort review:

- Publisher double-publish: the QueueEntry cleanup on a successful publish ran
  inside the publish try/except, so a delete failure was caught as a publish
  failure and scheduled a retry → re-dispatch. Isolated in its own try/except.
- assigned_slot_datetime vs scheduled_at divergence: a manual calendar drag
  (reschedule_post) moved pp.scheduled_at but left QueueEntry.assigned_slot_datetime
  stale; prioritize/reorder keyed on the stale field and could mis-place a post
  or double-book. They now key on pp.scheduled_at (the publisher's truth), and
  reschedule_post syncs the entry mirror.
- add_post_next_available / reslot_to_next_available now skip PROTECTED_STATUSES
  (the reslot endpoint could otherwise overwrite a publishing post's schedule).
- remove_from_queue clears the schedule for any non-protected child (e.g.
  failed), not just 'scheduled', so a removed failed post stops lingering on
  the calendar.
- Cross-queue concurrency: slot ops now lock the SocialAccount row (occupancy
  reads PlatformPost, which the per-queue QueueEntry lock didn't cover).
- clone_post deepcopies platform_specific_media and platform_overrides (not
  just platform_extra).
- Deleted the now-dead assign_queue_slots (no callers after the refactor).
- Tests: divergence (prioritize+reorder), reslot-protected no-op, remove-failed,
  publish-survives-cleanup-failure; froze now in SlotOccupancyQueueTests to kill
  a slot-boundary flake window.

* queue scheduling fix

* fix(tiktok): add PKCE to all OAuth flows (connect, reconnect, connection-link) (#103)

* fix(tiktok): add PKCE to OAuth connect flow

TikTok now requires PKCE on the authorization request (mandatory for
localhost/127.0.0.1 redirect URIs), so connecting an account failed with
errCode=10007 / error_type=code_challenge before the consent screen ever
appeared — get_auth_url sent no code_challenge.

Generate a code_verifier when starting a connect for PKCE-enabled
providers, stash it in the existing OAuth session payload alongside the
nonce, send the derived code_challenge on the authorize URL, and replay
the verifier on the token exchange.

Note the TikTok-specific quirk: it deviates from RFC 7636 — code_challenge
must be the HEX-encoded SHA256 of the verifier (not base64url), method
S256. Scoped behind a new SocialProvider.uses_pkce flag (default False),
so the other 10 providers are untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(providers): keep OAuth override signatures Liskov-compatible (mypy)

Widening the base SocialProvider.get_auth_url/exchange_code signatures to
accept the optional PKCE code_verifier left the 10 non-TikTok providers'
2-arg overrides incompatible with the supertype — mypy's [override] check
reported 20 errors and failed CI.

Add the same optional `code_verifier: str | None = None` param to each
override so every provider honours the base contract uniformly. The
non-PKCE providers ignore it; no behavior change (the view still only
generates/forwards a verifier for providers with uses_pkce=True).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(oauth): apply TikTok PKCE to reconnect and connection-link flows

The initial PKCE fix only covered social_accounts.connect_platform /
oauth_callback. Two other OAuth-initiation paths also reach TikTok and
still sent no code_challenge, so they failed with the same errCode=10007:
  - social_accounts.reconnect (re-authing an existing account)
  - onboarding connection_oauth_start / connection_oauth_callback
    (the public connection-link flow)

Centralise the verifier lifecycle in a new apps/social_accounts/oauth_pkce
helper pair (issue_pkce_verifier + pkce_kwargs) and route every init/
callback site through it, so connect, reconnect, and the connection-link
flow stay PKCE-consistent and a future entry point can't silently regress.

Tests: unit tests for the helpers, plus reconnect and connection-link
PKCE round-trip view tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* Request Facebook engagement permission (#90)

* docs(facebook): document pages_manage_engagement scope (#104)

Follow-up to #90, which added the pages_manage_engagement scope so
connected Facebook Pages can publish first comments.

- README: add pages_manage_engagement to the Meta "Manage everything on
  your Page" setup checklist, so self-hosters enable it in their Meta app
  (otherwise first-comment publishing silently fails).
- facebook.py: add a comment explaining why publish_comment() requires
  the scope.

* fix meta analytics metrics (#99)

* fix(meta-analytics): post-#99 follow-up (profile_views, post_id, IG follows, FB video permalink) (#105)

* fix(meta-analytics): follow-up fixes verified against Graph API v25 docs

Verified PR #99's metric integration end-to-end against Meta's Graph API
v25.0 docs and fixed the gaps found, plus folded in the one useful,
non-overlapping piece of PR #98.

- A1: drop the deprecated Instagram `profile_views` account insight (and the
  now-vestigial `profile_visits` catalog entry, mapping, and AccountMetrics
  field). It errored on every IG sync and mapped to a card no catalog listed.
- A2: `_get_post_fields` now retries without `post_id` on APIError. `post_id`
  is a Video-node field, not a Post-node field; on a feed post the request
  could 400 and silently zero comments & shares.
- A3: Instagram catalog `follows` -> `followers`, deriving follower growth
  from the profile follower total via `follower_growth_metric`'s existing
  `followers` branch (Meta deprecated the IG `follower_count` insight and
  `follows_and_unfollows` is unreliable).
- B (from #98): `_publish_video` resolves the feed post id + permalink
  (best-effort) so the stored URL is shareable. No inert id storage, no
  engine changes, no status polling.

Tests updated/added for all four. Supersedes #98.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(meta-analytics): address code-review findings

Follow-up review of this branch surfaced defects in the new code; fixes:

- _publish_video: the post-publish metadata GET now catches Exception (was
  only APIError/RateLimitError/httpx.HTTPError). A non-API error after the
  video is already live (e.g. .json() on a malformed 2xx body) would propagate
  and make the publish engine schedule a retry -> duplicate video post. (#1)
- Instagram get_account_metrics: a failed profile fetch now yields
  followers=None (not 0); _account_metrics_to_dict skips None, so a transient
  blip no longer writes a poisoning 0-followers snapshot that fabricates a
  drop+spike in the follower-growth chart. (#2)
- _sync_account_metrics: the cumulative followers total is persisted for the
  current day only, never backfilled into past dates (which fabricated flat
  follower history — the hazard TikTok's date-range guard prevents, but IG
  can't use that guard since its reach/views are date-ranged). (#3)
- _get_post_fields: only retry without post_id when the error is actually
  about post_id, so transient/auth/5xx errors don't double the failing calls. (#4)
- Fix the stale "today: TikTok" followers comment. (#8)

Tests added for #1-#4; pytest + ruff green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(meta-analytics): don't drop recovered Instagram follower total

Codex review of this branch: _sync_account_metrics popped the live ``followers``
total for every backfilled offset, so when on_date's own profile fetch failed
(or its row already existed) but a later offset fetched the current count, the
value was discarded and on_date never recorded a follower total — leaving a
permanent gap in the growth series.

Now the live total is captured from whichever offset returns it and written once
to on_date only (idempotently), never into a backfilled past date. TikTok's
behavior (incl. the 0-follower baseline) is preserved since it routes through the
same post-loop write. Adds a recovery regression test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(api): follower-growth key is now "followers" for Instagram (A3)

CI flagged a stale assertion in apps/api (outside the dir subset I ran locally):
test_happy_path_returns_derived_metrics expected the IG follower-growth key to be
"follows", but A3 switched Instagram's catalog to "followers" (growth derives from
the follower total). The router's behavior is correct; only the test expectation
was stale. Full suite (991) green locally.

* fix(tiktok): use video-only analytics scope (#106)

* fix(composer): enforce platform publish settings (#107)

* feat(composer): enforce TikTok branded-content × privacy UX rules (#110)

TikTok's Content Posting API UX guidelines require that branded content
can never be posted with private ("Only you" / SELF_ONLY) visibility. This
implements that in the composer's TikTok settings:

- Two-way mutual exclusion: enabling Branded content disables the "Only you"
  visibility option; selecting "Only you" disables the Branded content
  checkbox (with an informing message).
- Replace the native privacy <select> with a custom Alpine listbox so the
  disabled "Only you" option can show TikTok's required hover prompt
  ("Branded content visibility cannot be set to private."), which native
  <option>s never render. A hidden sr-only mirror input preserves the form
  field name and the required-validation gate.
- Handle the audit-locked case (unaudited app → only SELF_ONLY allowed):
  a ttBrandingBlocked getter + enforceBrandingLock() keep state valid so
  branded content can't strand the privacy field or the disclosure gate,
  and the impossible "branded + private-only" state can't be reached.
- Accessibility: arrow-key navigation, focus-on-open, Escape-to-trigger,
  roving tabindex, and close-on-focus-leave for the custom dropdown.
- cursor-pointer on the dropdown trigger.

UI-only enforcement; TikTok's API remains the publish-time backstop.

* fix: fix facebook analytics post ids (#109)

* fix facebook analytics post ids

* Fix view insights

* Fix Instagram post metric mapping

* Fix Instagram metric test expectations

* Provide the page id for Facebook first comments

* fix(docker): run migrations before app/worker in Quick Start (#112) (#115)

The Docker Quick Start could not publish posts. The `worker` service ran
`process_tasks` as soon as Postgres was healthy, but migrations were a
manual step run *after* `docker compose up`. The worker hit an empty schema
and crashed with `relation "background_task" does not exist`. With no
restart policy it stayed dead, so even after the user ran `migrate` the
publish background task never executed and posts stayed unpublished.

Mirror the proven docker-compose.prod.yml pattern in the dev Quick Start:

- Add a one-shot `migrate` service gated on `postgres: service_healthy`.
- Gate `app`/`worker` on `migrate: service_completed_successfully`.
- Add `restart: unless-stopped` to `app`/`worker` so a transient early
  race self-heals.
- Give the override's `migrate` the same dev settings/DATABASE_URL as
  `app`/`worker` so all three target the same database.
- Drop the now-redundant manual `migrate` step from the README Quick Start.

Fixes #112

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(approvals): redesign approval workflow (publish tab, client portal, on_hold) (#108)

* feat(approvals): redesign approval workflow per new design

Rebuilds the approval-workflow UI from the approved Figma/HTML design and
consolidates it onto a single canonical surface, while leaving the global app
shell untouched.

What changed
- Publish → Approvals tab rebuilt as one row per post (bundled actions):
  status badges, filter pills with counts, expand-to-preview, inline Review
  panel with required comment, Approve, Edit (changes_requested) and a
  word-level version-diff modal. Action endpoints now return a single
  204 + HX-Trigger (toast + list-refresh) shape via a shared helper, replacing
  the old per-caller partial branch.
- Deleted the orphaned standalone /approvals/ queue and the dead org queue
  (templates, views, URLs); kept the shared action/comment/version-diff
  endpoints.
- Composer approval panel: reviewer-feedback banner + full history; reviewer
  actions converted to HTMX + toast.
- Client portal rebuilt (needs-approval + approved/scheduled sections) with a
  new "request hold" flow.
- New on_hold editorial status: model status + transitions, derive_post_status
  awareness (a held child no longer masked by a sibling), a hold-aware publisher
  guard (locks all of a post's rows + re-checks under lock so a concurrent hold
  can't be raced past), request_hold/resume_hold services, client-portal
  endpoint, and an APPROVAL_HOLD_REQUESTED notification.
- Approvals tab honours the publish toolbar's channel/tag filters and preserves
  the active channel/tag/timezone across status pills and the post-action
  self-refresh.
- Shared apps/common/htmx.py toast helper; scoped toast/badge/diff partials.

Why
- The previous approval UI had two divergent internal queues (the better one
  unlinked), broken reviewer actions, and no client hold path. This unifies the
  surface, fixes the broken flows, and adds the hold capability from the design.

Also
- Renamed apps/approvals/tests_security.py and apps/calendar/tests_security.py
  to test_security.py so pytest's python_files pattern actually collects them
  (8 security regression tests were silently not running in CI).

Migrations: composer (PlatformPost.status), approvals (ApprovalAction.action),
notifications (EventType) — all choice-only AlterField.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(approvals): narrow membership for mypy in approvals tab context

`bool(membership) and membership.workspace_role` doesn't narrow the
`Any | None` membership for mypy (union-attr error on workspace_role).
Use `bool(membership and membership.workspace_role == "client")` so the
truthiness check narrows before the attribute access.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(approvals): pointer cursor on buttons + restore composer panel edits

- Add a low-specificity `button { cursor: pointer }` base rule (and
  `button:disabled → not-allowed`) to the shared _toasts.html partial, which is
  included on every approval surface (publish tab, composer, client portal /
  both shells). Explicit cursor utilities still win; disabled buttons stay
  not-allowed.
- Restore the composer approval-panel changes that were missing from the PR
  (compose.html had reverted to its original form): reviewer-feedback banner
  (uses latest_feedback), HTMX-ified reviewer Approve/Request-Changes with toast
  feedback, the pending_review/pending_client substring→explicit-OR fix, the
  toast host include, and the reload-on-approvalAction listener.

CSP: verified production-safe — script-src has no 'unsafe-inline' but uses a
nonce (CSP_INCLUDE_NONCE_IN=script-src); all inline <script> carry the nonce.
Inline styles/attributes are covered by style-src 'unsafe-inline'. No inline
event handlers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(approvals): edit + re-review path for rejected & approved posts

Closes the gap where rejected/approved posts in the Approvals tab had no way
to reach the composer, and editing an approved post couldn't re-enter review.

- Approvals tab: show the **Edit** button on `rejected` and `approved` rows
  (previously only `changes_requested`) so every post can reach the composer.
- Resubmit loop now accepts `approved`: add the `approved → pending_review`
  state transition and include `approved` in `resubmit_post`'s eligible states,
  so "Resubmit for review" works for an edited approved post. The composer's
  Submit/Resubmit buttons are now mutually exclusive (Submit for fresh drafts,
  Resubmit for changes-requested / rejected / approved).
- Option A safety net: editing an approved post's content and saving (explicit
  save_draft *or* the 30s autosave) silently reverts its approved channels to
  `pending_review`, so edited content can't publish without a fresh review.
  Change-guarded (an unchanged save keeps it approved). Adds an "Approved —
  saving changes sends it back for re-approval" banner in the composer.

Tests: resubmit accepts approved; edit-then-save and autosave revert approved →
pending_review; an unchanged save stays approved.
No migration (state-machine + eligibility are pure Python).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(approvals): media edits on an approved post also trigger re-review

Extends Option A to media: attaching a library asset, uploading a new file,
or removing media from an approved post now reverts its approved channels to
pending_review (via _revert_approved_to_review, a no-op for non-approved posts).
Previously only base-content edits (title/caption/first-comment/tags) re-triggered
review; media changes via the dedicated attach/upload/remove endpoints slipped
through.

Tests: attaching and removing media on an approved post revert it to pending_review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(composer): merge migration to resolve 0018 conflict with main

main added composer/0018_normalize_facebook_platform_post_ids (#109) after this
branch created composer/0018_alter_platformpost_status, leaving two leaf nodes.
Merged origin/main and added a merge migration so the graph is linear again
(CI's `migrate` was failing with "multiple leaf nodes").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(approvals): close three approval-workflow review findings

- composer: editing an approved post and scheduling/publishing in the SAME
  save now re-enters review instead of shipping un-reviewed content. The
  re-review revert runs before the publish transition, and the reverted
  children are excluded from it so a same-save publish can't drag them back
  out of review.
- calendar: the approvals tab's channel + status filters now resolve against
  the SAME PlatformPost row via an Exists subquery. Chained
  .filter(platform_posts__...) calls spawned separate joins, so a post pending
  on one channel could surface (and be bulk-actioned) under a different
  channel. Removes the now-unused _apply_publish_filters helper.
- client portal: card affordances derive from child statuses
  (client_pending / client_on_hold / client_approved) instead of the derived
  Post.status, so a pending_client child isn't hidden by a lower-ranked
  sibling (draft/changes_requested/on_hold) that wins the aggregate.

Adds regression tests covering all three paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(approvals): close on_hold + portal gaps from full-PR review

Functional/correctness:
- notifications: add DEFAULT_CHANNELS entry for APPROVAL_HOLD_REQUESTED so
  client-hold notices actually email reviewers (was dispatching on no channel).
- publisher: _process_retries now carries the same on_hold exclude + per-post
  re-check as the primary due-query, so a retrying child can't publish past a
  hold on a sibling.
- status: derive_post_status returns partially_published instead of on_hold
  when a channel has already published, so a live channel isn't masked (and
  is_editable/is_schedulable aren't wrongly flipped off).
- client_portal: order decided_posts with scheduled_at NULLs first so
  approved-but-unscheduled posts aren't the first dropped by the [:50] cap;
  batch the queue comments into one query (was an N+1 per post).
- approvals: suppress the author "Post approved" notification when two-stage
  mode only advanced the post to pending_client (still awaiting client sign-off).
- approval_extras: add the partially_published status badge (was falling back
  to a grey "Draft" pill for partly-published posts).

UI coverage & UX:
- calendar: add an on_hold count + Hold filter pill to the approvals tab.
- composer: add an on_hold status banner; disable the reviewer "Request changes"
  submit until a comment is entered.
- client_portal: portal action errors now return an error toast (shared
  _portal_error helper) instead of a bare 400 with no feedback.

Cleanup:
- approvals: extract the triplicated reviewer-notify loop into _notify_reviewers.
- composer: bound the approval-history render to 50 rows.

Adds regression tests: derive masking, retry-path hold guard, hold-notification
channels, and two-stage notification gating.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* style(approvals): ruff format test_workflow.py

* fix: Facebook analytics sync (#114)

* fix facebook analytics post ids

* Fix view insights

* Fix Instagram post metric mapping

* Fix Instagram metric test expectations

* Provide the page id for Facebook first comments

* Prefer fresher post metrics & refresh empty…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants