- Add a PublishToSocialPlatform test that runs the real job -> publisher chain (no publisher mock) for a linkedin_document post, asserting the documents API flow and the published platform state
- resolveDocumentTitle selects the document item explicitly (first(isDocument)) to match publishDocument, instead of the first media item
Review follow-ups before QA:
- Editor: getMediaIncompatibilityReason rejects a PDF on non-document content types, so the schedule gate and variant auto-snap match the backend rule (compliance i18n in en/es/pt-BR)
- MCP UpdatePostTool: validate effective content_type vs stored media on schedule, closing the schedule-without-content_type gap; share entriesForUpdate/errorsFor with the API path
- Tests: Platform allowedMediaTypes contains Document, URL-attach of a PDF (LinkedIn ok / TikTok rejected), multi-platform PDF rejection, document init-failure/missing-URN, Page publisher PROCESSING_FAILED
Make the document (PDF) exclusivity validation — previously web-only — also apply when scheduling/publishing via the public API and MCP, so a misconfigured post can't slip through these entry points.
- ContentTypeCompatibleWithMedia: stored-media fallback for partial updates + a stored-state assertStoredPostCompatible(Post)
- MCP PublishPostTool: assert stored-state compatibility before publish (the media-side mirror of assertStoredPostPublishable)
- API UpdatePostRequest: validate each platform's effective content_type against effective media on schedule/publish (covers publishing without resubmitting content_type)
- MCP UpdatePostTool: apply the rule on schedule with stored-media fallback
- Tests: API + MCP happy + rejection paths, plus rule fallback/precedence units
Add LinkedIn document posts — the swipeable PDF carousel — for both personal profiles and company pages. This is the format every major competitor exposes via native PDF upload, and the reason a trial user churned.
- New 'document' media type (application/pdf) across the upload pipeline (Type enum, HasMedia, FormRequests incl. chunked, Platform media types)
- New LinkedInDocument / LinkedInPageDocument content types: PDF-only, single-file, with a supportsDocument() flag
- Publisher flow: documents initializeUpload -> PUT -> poll AVAILABLE -> post with content.media.{id,title}; optional document_title meta (falls back to file name)
- PDF is mutually exclusive with image/video, enforced in ContentTypeCompatibleWithMedia
- Frontend: 'Document (PDF)' variant, media rules (100MB cap), composer/gallery/detail PDF cards, real PDF embed in the LinkedIn editor preview, i18n in en/es/pt-BR
- Tests: publishers (personal + page, incl. processing-failure path), enums, compatibility rule, chunked PDF upload, API + MCP document_title round-trip
LinkedIn caps documents at 100MB / 300 pages (Documents API). The page limit is enforced by LinkedIn at publish, not validated client-side.
The persona options mix individual roles (creator, developer, marketer) with
organization types (startup, agency, store), but the prompt only asked what
describes 'you'. Make it read intentionally:
- Broaden the prompt to 'you or your business'.
- Creator -> Content creator and Marketer -> a proper role label (es/pt:
Profesional/Profissional de marketing) so the role group is consistent.
Completes the persona grid to a 3x3 with two high-value segments:
Marketer (in-house marketing / social media, a scheduling tool's core user)
and Online store (e-commerce). Labels in all three locales, with
IconSpeakerphone and IconShoppingBag glyphs. Other stays last.
Adds a Developer option to the onboarding persona step (after Freelancer),
with labels in all three locales (Developer / Desarrollador / Desenvolvedor)
and an IconCode glyph in the persona grid. The option flows to the frontend
via Persona::cases() and is accepted by the existing enum validation.
A Bluesky post embed is images XOR video, so a post that carries both can't
be published there. Enforce it per selected platform:
- Add ContentType::supportsMixedMedia() (false only for BlueskyPost) and reject
image+video in ContentTypeCompatibleWithMedia when the type forbids it.
- Mirror it client-side (useMediaRules forbidsMixedMedia + usePostCompliance)
so the editor blocks scheduling with an inline reason before submit, like
every other per-platform compatibility check.
- Add the no_mixed_media message in all three locales (en/es/pt-BR).
- Cover the rule and enum, including the GIF-counts-as-image case.
Builds on the video-upload feature with house-style cleanup and fixes:
- Extract downloadToTempFile / unwrapJobStatus / videoUploadFormat helpers and
guard tempnam/fopen on the image path too (no magic numbers; named consts).
- Send the real content-type and extension for Bluesky's four accepted formats
(mp4, mpeg, webm, mov), falling back to mp4 for anything else.
- Mint the upload and status service-auth tokens once and reuse them.
- Bound the whole upload/poll/retry flow to a wall-clock budget under the queue
job timeout so a stuck transcode degrades to a text post instead of being
killed mid-flight.
- Config-drive the poll interval, max video size, and video-service hosts.
- Expand BlueskyPublisherTest to cover every branch: 409 with and without a
blob, upload/status token failures, did:plc fallback and did:web resolution,
the format mapping, retries, and timeout.
Address review feedback and a transient failure mode seen in production:
- Guard tempnam()/fopen() returning false (bail cleanly instead of a
TypeError later) and guard the finally-block unlink() with is_string().
- pollVideoJob() now bails on a non-2xx getJobStatus (e.g. expired token)
instead of sleeping to the timeout and masking the real error.
- resolvePdsEndpoint() maps did:web colon segments to host + path per the
did:web spec (did:web:example.com:user:alice -> .../user/alice/did.json)
instead of treating the whole remainder as a host.
- The video service occasionally fails a job transiently
(JOB_STATE_FAILED "Failed to process video") for valid input, so the
upload+poll is retried up to VIDEO_UPLOAD_ATTEMPTS times (split into a
new attemptVideoUpload() helper; the upload service-auth token is minted
once and reused across attempts).
Adds a test covering retry-after-transient-failure. Full suite (23) green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
BlueskyPublisher only attached images; posts whose media was a video
published as text-only with no embed. Bluesky videos do not use the PDS
uploadBlob path — they go through the separate video service
(video.bsky.app), which transcodes the file and writes the resulting blob
back to the account's PDS.
Add a video path that runs when no image embed was built (a post carries
either images or one video, never both):
1. Resolve the account's real PDS host from its DID document (entryway
accounts store bsky.social as `service`, but the video service-auth
audience must be the actual *.host.bsky.network PDS).
2. Mint a com.atproto.server.getServiceAuth token scoped to uploadBlob.
3. POST the bytes to app.bsky.video.uploadVideo.
4. Poll app.bsky.video.getJobStatus until the blob is ready, then embed
it as app.bsky.embed.video.
Failures (download, oversized >100MB, service-auth, transcode) log and
return null so the post still publishes as text rather than crashing the
job — mirroring uploadBlob(). New NSID constants live in BlueskyLexicon
and the video service / PLC directory hosts are configurable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- store(): members without connected accounts no longer get redirected into
the now-admin-only /accounts (403); non-managers go to the calendar with
the same flash, admins still go to /accounts
- cover the SyncPostPlatforms can('update') gate (viewer creates no platform
rows; member does) and the store redirect split, in WorkspaceRolePermissions
- cover PostPolicy::duplicate viewer-denied
- docs sidebar link uses the canonical https://docs.trypost.it
- drop orphaned sidebar.support.{discord,last_updates} keys in all locales
- remove the explanatory isLocked comment in Edit.vue
Add a Support group at the bottom of the sidebar with Share feedback,
Earn 30% referral (affiliates), Stay updated (X) and Documentation.
Share feedback and Documentation move out of the user menu into this
section. Adds the referral/stay_updated labels in all three locales.
Drop the 60% opacity / pointer-events lock on the editor's left column so
the client sees the draft clearly and can still click an image to open the
lightbox. Instead the composer renders read-only: the add-image tile,
remove/drag controls and the formatting toolbar are hidden and the caption
textarea is read-only, while the media click-to-preview stays active.
Viewers are typically the client: they need to open a draft in the editor
to use the comments tab, but must not change anything.
- post editor (edit) now authorizes view, so viewers can open it; the
composer + schedule tab render read-only and the comments tab stays
interactive (defaults to the comments tab for viewers)
- all mutations stay member+ (update/delete) — the autosave/save/publish/
schedule/delete affordances are hidden and the PUT is still 403 for
viewers; SyncPostPlatforms only runs for users who can update
- drafts route to the editor for everyone again (reverts the read-only
Show detour); Show stays the published-post view
- /accounts now authorizes manageAccounts (admin+), so viewers and members
get 403; the Connections sidebar item is admin+ only and the connect/
disconnect grid is reverted to main (no per-button gating needed)
Tests: draft→editor redirect for every member, viewer can open the editor,
viewer cannot save, and only admins+ can open /accounts.
A viewer clicking a draft/scheduled post landed on the editor route
(authorizes update) and got a 403. The post list/calendar routed every
editable post to the edit page, and PostController@show redirected
draft/scheduled posts to the editor for everyone.
- show only redirects to the editor when the user can update the post;
viewers get the read-only Show page
- posts index + calendar route to show (not edit) when the user cannot
create posts
- cover viewer-sees-show, member-redirected-to-editor, and
viewer-403-on-direct-edit in WorkspaceRolePermissionsTest
Viewers could mutate posts, automations and trigger AI write endpoints,
and every role saw create/manage affordances that 403'd on click.
Backend (security):
- PostPolicy update/delete now require member+ (was tenancy-only), which
also gates the AI write endpoints that authorize('update')
- AutomationPolicy create/update/delete require member+; activate/pause
delegate to update
- AutomationController authorizes index/store/show; AnalyticsController
authorizes view
- Comments stay open to members incl. viewer (by design)
Frontend (UI gating via new useWorkspaceRole composable):
- Sidebar: create post / create workspace / automations / library nav
- Accounts grid: connect / disconnect / reconnect (admin+)
- Members: invite / change role / remove / cancel invite (admin+)
- Account billing tab (owner); posts index + calendar create affordances
Tests: PostPolicyTest, AutomationPolicyTest (all four roles) and an
end-to-end WorkspaceRolePermissionsTest; aligned the automation test
suites' account/workspace setup with role pivots.
All customers were migrated to the single Workspace plan and the old plan rows
were deleted in production, so drop the now-dead legacy tiers from the code:
reduce the Plan Slug enum to Workspace only and default the PlanFactory to it.
Rework StripeEventListenerTest around the single Workspace plan (its monthly and
yearly price ids still exercise plan-by-price mapping, trial clearing, deletion,
and previous-plan propagation), and point the remaining tests that referenced
the starter/pro slugs at the seeded Workspace plan.
The remove/role-change guards only protected the account owner, so a non-owner
admin could change their own role or remove themselves via a crafted request
(the UI hides it, but the backend didn't). Add an explicit self-guard to both
updateRole and removeMember.
Lock the whole role system with tests: accept assigns the exact invited role
(viewer/admin/member), invite requires and persists a role, updateRole supports
viewer and blocks self/owner/invalid, removeMember blocks self/owner, and a
viewer is read-only (view yes; create post / manage team / invite no).
AcceptInviteController attached invited users with a hardcoded member role,
ignoring the invite's role entirely (a viewer invite joined as member). Use the
invite's role on accept, require role on invite creation (no silent default),
and drop the role from the request/CreateInvite defaults. Add the Viewer option
to the member role dropdown (now iterates all roles), hide the dropdown on the
current user's own row, and require the member's email to confirm removal.
Covered by tests asserting the accepted role matches the invited role for
viewer/admin/member, plus invite role validation.
The onboarding/connect and accounts screens now share the same NetworkConnectGrid
with the same popup-close-and-reload flow, so the social_connect_onboarding flag
no longer affects anything in the normal path. Remove the frontend plumbing
(useOAuthPopup query param, the grid's onboarding prop) and the dormant backend
scaffolding (the session flag in 6 OAuth controllers, getRedirectRoute, and the
ignored isOnboarding arg); the YouTube no-channels error now redirects to
app.accounts directly. Also drop the unused legacy plan price-id env vars from
.env.example (only the Workspace plan remains).
Pennant: all feature flags were replaced by BillingCycle, so remove the package
(composer), the now-empty app/Features discovery, the pennant-development skill,
and replace the create_features_table migration with a drop_features_table.
Timezone: the registration flow collected a user timezone (seeder, request
validation, hidden field) but no timezone column ever existed and CreateUser
discarded it. Remove the dead handling, the orphaned Timezone rule, and the
tests that covered the now-removed validation.
All Pennant features were removed (replaced by BillingCycle), so app/Features no
longer exists. Git does not track empty dirs, so a clean CI checkout has no
app/Features directory and Feature::discover() throws in package:discover,
failing the build before tests run. Remove the now-dead Pennant bootstrap
(discover/resolveScopeUsing/useMorphMap) and its import.
- LinkedInController: catch NetworkAlreadyConnectedException so a profile
colliding with a connected Page shows the specific network_taken message
instead of the generic error (+ test).
- Billing.vue: pass a string to transChoice replacements (vue-tsc TS2322).
- NetworkConnectGrid: drop the now-unused 'connect' emit (no listener after
AddSocialDialog removal).
- Tests: assert the BillingCycle trial window; fix a stale section comment in
StripeEventListenerTest.
Changing the language re-renders the UI in the new locale already, so the
'Language updated' banner was redundant. Remove the flash and its orphaned
i18n key.
Send the user's persona on both the server-side subscription.created event and
the client-side checkout.completed/dataLayer purchase event, for ICP analysis.
Hold the processing screen ~5s before redirecting so PostHog and the ad pixels
(Google/Meta via GTM) reliably flush. Sharpen the annual-upgrade banner copy
(lead with '2 months free') and give its check icon a white tile.
Emphasize Google/GitHub on the register page and hide the email form behind a
'Sign up with email' toggle that reveals it (shown directly when no social
providers are configured). Even spacing across all three buttons, an optional
hideDivider prop on SocialLogin, and a stronger value-led headline/subtext.
Add a connect step between persona selection and Stripe checkout: persona ->
connect >=1 network (server-enforced) -> checkout. Extract the network grid into
a shared NetworkConnectGrid used by both onboarding and the accounts page, which
is redesigned from a table into the same grid (one account per network, with
per-card connect / connected+disconnect / reconnect states). Drop the openDialog
auto-open flow now that networks are shown inline.