Move PostObserver, AutomationRunObserver, and AutomationNodeRunObserver onto
their models with the #[ObservedBy] attribute (the Laravel-preferred way) and
drop AppServiceProvider::configureObservers() plus its now-unused imports.
Conflict resolutions + integration fixes:
- CreatePost: kept the branch's merge-into-existing meta persistence (equivalent
to main's #86 replace on create, and what the automations flow was built on).
- FacebookSettings.vue: kept both new defaults (previewOnly + meta).
- RunGenerateNode + GenerateNodeConfig.vue: ContentType::InstagramCarousel was
removed on main (#80); an IG carousel is now a multi-image instagram_feed, so
the carousel-capable list uses InstagramFeed.
- GenerateNodeTest: fixtures use the ContentType enum and the new instagram_feed
carousel signal.
The tab holds publish targets, per-platform settings, and labels — not
scheduling (the date/time control lives in the editor header). Renames the
i18n key posts.edit.tabs.schedule -> tabs.channels (Channels / Canais /
Canales) and the single $t reference. The scheduling button string and the
tab's internal value are untouched.
Review follow-ups:
- AspectRatio::toFloat() now owns the crop ratio math; CropsImageForAspectRatio
delegates to it so the enum is the single source of truth for both validation
and cropping (no more parallel literal map).
- API update controller now reloads postPlatforms before returning, so the
update response reflects the persisted platform meta/content_type (was stale).
- Tests: AspectRatio enum unit test; API valid-update read-back + 'original'
on create; MCP response read-back + valid update.
PR #82 made `meta.aspect_ratio` crop Facebook (and already Instagram) feed
images at publish time, but the API and MCP surfaces only half-supported it:
you couldn't set meta at creation, the value wasn't validated, and responses
never returned it. This closes those gaps.
- New `AspectRatio` enum is the single source of truth for the allowed ratios
(1:1, 4:5, 16:9, original). App/API/MCP requests now validate via
`Rule::enum(AspectRatio::class)` — an invalid ratio is rejected everywhere
instead of silently center-cropping to square.
- API `StorePostRequest` and MCP `CreatePostTool` now accept `platforms.*.meta`;
`CreatePost` persists it. MCP create documents `meta` in its schema.
- `Api\PostPlatformResource` now exposes `meta`, so API and MCP responses return
the aspect_ratio (and other per-platform meta) a client set.
Replace the additive LINKEDIN_EXTRA_SCOPES approach with a single
full-override env var per flow, exploded into an array at the config
layer (config/trypost.php -> platforms.linkedin{,-page}.scopes). This
keeps env values as plain comma-separated strings, lets self-hosters
override the entire set in one place, and removes the controller-side
scope-merge logic.
- config/trypost.php: explode LINKEDIN_SCOPES / LINKEDIN_PAGE_SCOPES
into the scopes arrays (deprecated r_basicprofile stays out of the
personal default)
- LinkedInController: drop resolveScopes(), read config scopes directly
- LinkedInPageController: drop the hardcoded $scopes property, read
config scopes at both call sites
- tests: drive the connect scope assertions from config overrides
- .env.example, docker/.env.docker.example: document LINKEDIN_SCOPES
and LINKEDIN_PAGE_SCOPES
Consolidate the default scope set alongside the existing LinkedIn host
config under config/trypost.php -> platforms.linkedin, matching the
project convention that per-platform service config lives there. The
default still drops the deprecated r_basicprofile scope, and
LINKEDIN_EXTRA_SCOPES stays additive (merged onto the defaults rather
than replacing them) so operators can opt back into legacy scopes
without risking a misconfigured full-replacement.
- config/trypost.php: add scopes + extra_scopes to platforms.linkedin
- config/services.php: drop the moved extra_scopes key
- LinkedInController::resolveScopes(): read both from trypost config
- tests: repoint config() overrides to the new key
Make r_basicprofile opt-in via LINKEDIN_EXTRA_SCOPES so self-hosted users
unblock by default and ops with legacy/enterprise products keep working.
Why
---
LinkedIn rejects OAuth authorize requests with a generic "Bummer,
something went wrong" page when an app asks for a scope it can't grant.
`r_basicprofile` is a legacy scope deprecated in 2018; new LinkedIn dev
apps don't have it, so every self-hosted user hits the rejection
immediately on `/connect/linkedin`.
The two products LinkedIn actually grants to standard apps today are:
- Sign In with LinkedIn using OpenID Connect → `openid profile email`
- Share on LinkedIn → `w_member_social`
That set is enough for the connect flow. The only piece of data
`r_basicprofile` was buying us is `/v2/me`'s `vanityName` (pretty
`linkedin.com/in/<slug>`). `fetchVanityName()` already handles HTTP
failure gracefully (returns null), and the only downstream consumer —
`LinkedInPagePublisher`'s post-URL builder — already falls back to a
numeric `linkedin.com/feed/update/<id>` URL when `$account->username`
is null.
Backward compatibility
----------------------
Ops with legacy or enterprise LinkedIn products approved on their dev
app (so they DO have `r_basicprofile`) can opt back in via env:
LINKEDIN_EXTRA_SCOPES=r_basicprofile
`LinkedInController::resolveScopes()` merges this comma-separated list
into the default scope array. The connect flow's `Socialite::scopes()`
call then includes the legacy scope, preserving the pre-PR behaviour
end-to-end (including `vanityName` lookup).
Net effect for users without `r_basicprofile`:
- Connect flow works (was previously rejected by LinkedIn).
- Posts publish exactly the same way.
- Generated post URLs use the numeric form instead of the vanity slug.
Tests
-----
- `linkedin connect requests the default scope set when LINKEDIN_EXTRA_SCOPES is unset`
- `linkedin connect appends LINKEDIN_EXTRA_SCOPES to the default scope set`
- Existing `splits comma-separated approvedScopes` fixture updated to
match the new default set.
Removed the logic that conditionally seeds the UserSeeder based on the self-hosting configuration. This simplifies the DatabaseSeeder and assumes user data will be handled externally.
Added logic to the DatabaseSeeder to call UserSeeder only if the application is configured for self-hosting. This ensures that user data is seeded appropriately based on the deployment environment.
- Instagram carousel and Facebook multi-image crop tests now run as datasets
over 1:1 and 4:5, asserting each image's real cropped ratio (previously 1:1
only) — proving the chosen ratio flows through the multi-image paths.
- Add a Facebook test that a multi-image post aborts entirely (throws
FacebookPublishException) when one image can't be downloaded for cropping.
- CropsImageForAspectRatio: drop the per-publisher path-prefix argument; crops
from both platforms go to a single `social-crops/` directory (the folder has
no functional meaning — the platform fetches by URL and nothing reads it back;
UUID filenames prevent collisions).
- Close the IG/FB test asymmetry surfaced in review: Instagram single-image crop
now runs across 1:1/4:5/16:9 (dataset) and has a crop-download-failure test;
Facebook gains an explicit 'original' bypass test. Both platforms now cover the
same matrix (crop per ratio + uploaded-URL is the crop, original/no-meta
bypass, multi-image, download-failure exception).
Follow-up tracked in #83 (orphaned crops are never pruned from storage).
Facebook feed posts now have the same aspect ratio selector as Instagram
(1:1, 4:5, 16:9, Original), letting users control how their image is cropped
before publishing instead of leaving it to Facebook's auto-crop.
The Facebook Graph API has no crop/aspect-ratio parameter for organic Page
photo posts (image crops are ads-only) — Facebook displays the bytes it
receives at their native ratio. So, exactly like Instagram, the crop is done
server-side before upload.
- Extract the Instagram crop logic into a shared CropsImageForAspectRatio
concern (download -> crop -> store -> URL), with a per-publisher
cropFailureException hook. InstagramPublisher refactored to use it
(behaviour identical; existing tests unchanged).
- FacebookPublisher reads meta.aspect_ratio and crops single- and multi-image
feed posts (facebook-crops/ prefix). Reels/Stories are unaffected.
- FacebookSettings: aspect ratio picker on the Feed variant (default Original
-> no crop). ScheduleTab wires meta; FacebookPreview reflects the chosen
ratio. Adds posts.form.facebook.aspect.* in en/es/pt-BR.
Closes#69
Instagram carousels were stored as content_type=instagram_carousel, which the
publisher's match() did not handle — publishing failed with "Unsupported
Instagram content type: instagram_carousel" for any post created via API, MCP,
or template (the AI flow worked only thanks to an inline band-aid that rewrote
carousel to feed before saving).
A carousel is just an Instagram feed post with multiple images: the editor,
preview, and publisher already treat a multi-image feed as a carousel. So
instagram_carousel is a generation format, not a stored content type. Remove it
from the ContentType enum entirely; it now lives only as an AI generation-format
string (wizard card + slide structure + carousel templates are untouched), and
posts always persist as instagram_feed.
- ContentType: drop the InstagramCarousel case; InstagramFeed maxMediaCount 1 -> 10
- StreamPostCreation: resolvedContentType() maps carousel -> feed; band-aid removed
- StartPostCreationRequest: accept instagram_carousel as a generation format
- Frontend: carousel becomes a wizard-local AiFormat; UI/UX unchanged
- API/MCP now reject instagram_carousel as a content_type (Rule::in no longer lists it)
The calendar read each post's caption from post_platforms[0].content,
but post_platforms has no content column — the caption lives on
posts.content. As a result every cell fell back to the 'no content'
placeholder. Read post.content instead (matching the list view) and
drop the phantom content field from the PostPlatform interface.
Also surface the caption in the month view (it previously showed only
the time and platform icons), add horizontal padding to the month post
list so the card border is not clipped inside the overflow-y scroll
container, and standardise the hover effect across day/week/month
views to a shadow-only highlight (no lift).
Closes#70
The Workspace and Brand settings tabs both submit to the same
updateSettings endpoint, but the Workspace tab sends only the name.
UpdateWorkspaceRequest marked brand_font and image_style as required,
so the name-only request failed validation silently (the form only
renders the name error). Mark both brand fields as sometimes|required
so a name-only update succeeds while the Brand tab still validates them.
Closes#74
The Vue computed property returns false for allow_comments when meta lacks
the key (so the UI checkbox renders unchecked), but the backend defaulted
allow_comments to true if the key was absent — sending disable_comment=false
to TikTok. A post created without the user toggling the comment checkbox
went out with comments enabled even though the UI showed them off.
Align the backend default with what the UI displays: when meta omits
allow_comments, treat it as off (matching duet and stitch, which already
default to off). Adjust the existing default-settings test that locked in
the old behaviour.