Collapse LinkedIn to one content type per account kind (linkedin_post, linkedin_page_post). Publishers infer the publish format from the attached media — text, single image/video, multi-image carousel, or PDF document — matching how facebook_post/x_post already work; PDF is exclusive of any other attachment. Removes the editor variant picker, keeping only the PDF document title field. Includes a data migration collapsing the retired carousel/document content types.
Replace the two LinkedIn account cards with a single Connect LinkedIn button: one unified OAuth grant (linkedin-openid driver, union of scopes) then a post-callback identity picker to post as the personal profile (linkedin) or a company page the member administers (linkedin-page). The chosen organization is validated against the admin-verified list from the OAuth grant. Per-capability gating via LINKEDIN_ENABLED / LINKEDIN_PAGE_ENABLED supports profile-only or org-only self-hosting. Removes LinkedInPageController, LinkedInTokenSynchronizer, the standalone linkedin-page connect routes, and the unused redirect_page config.
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.
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.
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.
Facebook Stories do not work reliably with images via the Graph API.
Align validation and publisher with video-only (like Reel), while
Instagram Story continues to accept image or video.
Co-authored-by: Cursor <cursoragent@cursor.com>
Threads posts over 500 chars were saved + scheduled successfully and only
failed inside the publish job. The frontend already showed the 537|500 badge
but `canSchedule` ignored content length, so Schedule and Post Now stayed
enabled. Backend `UpdatePostRequest` only capped at 63206 (Facebook's max),
not per-platform.
- Add `Platform::contentOverflow()` as the single source of truth and reuse it
from `HasSocialHttpClient::validateContentLength` (publish-time).
- New `ContentFitsPlatformLimits` rule applied to the `content` field on
`App\\UpdatePostRequest`, `Api\\UpdatePostRequest`, and `Api\\StorePostRequest`
via `Rule::when(...)` so drafts are not blocked.
- Rule dedupes per platform (two Threads accounts -> one error) and reports
the platform label, hard cap, and overage via i18n.
- Edit.vue feeds `contentLengthOverflows` into `canSchedule` and lists each
offending platform in `postActionTooltip` using the existing
`getPlatformLabel` resolver.
The previous suite asserted happy paths and a couple of basic field
omissions but didn't probe the rules themselves. Adds 26 tests
across 5 files:
REST API (tests/Feature/Api/PostApiTest.php) — 9 new:
- content_type not in the enum
- content_type mismatched with the social account's platform
- label_id from another workspace
- platforms[].id from another post on update (cross-post leak)
- content_type mismatched with the post_platform on update
- status=scheduled requires future scheduled_at
- status=draft works with no scheduled_at
- past scheduled_at on store
MCP create-post-tool (tests/Feature/Mcp/PostToolTest.php) — 5 new:
- inactive social account
- content_type not in the enum
- content_type mismatched with the social account's platform
- label_id from another workspace
- already had: scheduled_at past
MCP update-post-tool (tests/Feature/Mcp/PostPublishToolTest.php) — 2 new:
- platforms[].id from another post (regression for the new
Rule::exists scoping)
- content_type mismatched with the post_platform
MCP attach-media-from-url-tool (tests/Feature/Mcp/AttachMediaFromUrlToolTest.php) — 3 new:
- non-http(s) scheme (ftp://...)
- malformed url string
- more than 10 URLs per call
Custom rules unit tests — 2 new files:
- ContentTypeMatchesPlatformTest covers happy path,
cross-platform mismatch, the Instagram + InstagramFacebook
compatibility bridge, and the no-op cases (missing account_id,
unknown content_type — those are caught by Rule::in elsewhere).
- ContentTypeMatchesPostPlatformTest covers the equivalent shape
for the update flow that pivots through post_platform.id.
Settings pages:
- Redesign layout to match Sendkit (max-w-4xl, space-y-12, Separator sections)
- Merge Members page into Workspace settings with Table, invite Dialog, ConfirmDeleteModal
- Add workspace logo upload/delete routes and controller methods
- Translate all hardcoded strings in Workspace.vue modals
Language system:
- Drop languages table, replace language_id FK with locale string column on users
- Create config/languages.php for available languages and default locale
- Add Spanish (es) translations (13 files)
- Simplify HandleInertiaRequests, ProfileController, RegisteredUserController
Code quality:
- Add declare(strict_types=1) to all PHP files
- Fix MastodonPublisher using wrong attribute (filename -> original_filename)
- Fix HasMediaTest for new has_photo/photo_url accessors
- Fix PublishToSocialPlatformTest type error revealed by strict_types
- Remove orphaned Language model from AppServiceProvider morph map
- Update User TypeScript interface (has_photo, photo_url, locale)
- Eager load media relation on workspaces to prevent N+1
- Add 8 new tests for workspace logo upload/delete
- Update workspace settings test to assert members/invitations props
All 710 tests passing.