Commit graph

245 commits

Author SHA1 Message Date
Paulo Castellano
2585d89cb4 Address PR review: connect-status enum, publisher cleanup, fill test gaps 2026-06-13 22:38:23 -03:00
Paulo Castellano
deb1c6fa69 Extract Telegram webhook registration into an action 2026-06-13 22:20:52 -03:00
Paulo Castellano
a4cf8aa4ce Add Telegram connection flow (controller + webhook)
Connect a channel by issuing a one-time code the user posts as /connect <code>
in their channel. A secret-token-guarded webhook matches the code, links the
channel as a SocialAccount (chat_id in meta), and records it on the request so
the connect endpoint can poll for completion. Adds the TelegramConnectRequest
model + migration, the connect/status endpoints, the public webhook route (CSRF
exempt), a ConnectionVerifier branch (getChat liveness), and a telegram:set-webhook
command. Tests cover the code issue, webhook link, secret rejection, expired/
unknown codes, status polling, and the command.
2026-06-13 21:39:03 -03:00
Paulo Castellano
9634e88e5d Add Telegram publishing (backend foundation)
Register Telegram as a platform: Platform/ContentType enum cases, a
platforms.telegram config block (shared bot token via env), TelegramPublisher
(sendMessage / sendPhoto|Video|Document / sendMediaGroup over the Bot API, HTML
parse mode, 4096 limit with long text split off a 1024 caption), wired into the
publisher dispatch. Add a Telegram ContentSanitizer branch (Telegram-allowed
HTML + ampersand escaping), MediaOptimizer/profile-url/factory support, and the
TelegramPublishException. Tests cover text, single media, album, long-text split,
overflow, API errors, private-channel URLs, and sanitization.
2026-06-13 21:25:31 -03:00
Paulo Castellano
9864ea96b6 Cover handle-resolution errors and configured web-app host
Add a test that a network error during handle resolution degrades the mention
to plain text instead of failing the post (exercises the resolveHandleToDid
try/catch), and a test asserting the post URL is built from the configured
web_app host (a revert to a hardcoded host would now fail).
2026-06-13 20:30:41 -03:00
Paulo Castellano
444d522012 Lock lexicon values and the URL paren-keep branch in tests
Add a unit test asserting each BlueskyLexicon constant equals its AT Protocol
NSID — a wrong value (not just a typo'd name) would otherwise fail silently at
runtime. Add a test that a URL with a matching '(' keeps its trailing ')'
(Wikipedia-style), covering the previously-untested keep branch of the trim.
2026-06-13 20:25:05 -03:00
Paulo Castellano
c86596ada3 Assert image embed and 4-image cap in tests
The image tests only checked that createRecord was sent — the faked empty
download meant no blob was ever attached, so they never verified the embed.
Drive a real (mocked) optimize + upload and assert the createRecord record
carries an app.bsky.embed.images embed, and that six images upload exactly four.
2026-06-13 20:07:25 -03:00
Paulo Castellano
2ff34369e0 Stop URL facets swallowing trailing punctuation
The URL regex captured trailing sentence punctuation and an unmatched closing
paren ("see https://x.com).") into the link facet, so Bluesky rendered the
punctuation as part of a malformed link. Trim it like the official atproto
tokenizer. Add tests for the trim and for byte-offset correctness after
multibyte characters (emoji/accents).
2026-06-13 20:02:54 -03:00
Paulo Castellano
60094c9316 Simplify handle resolution to a single AppView call
resolveHandle is a public read served by the AppView, so drop the PDS-first
auth, the multi-endpoint fallback loop and the threaded service/account args in
favor of one unauthenticated GET against config public_appview — matching how
BlueskyAnalytics already reads from the AppView. The try/catch stays so a
network error degrades the mention to plain text instead of failing the post.
Remove the now-obsolete fallback test.
2026-06-13 18:41:43 -03:00
Paulo Castellano
5227cde5c7 Cache unresolvable handles and tighten offset types
Memoize by key (array_key_exists) so a handle that fails to resolve is tried
once per post instead of on every repeat occurrence, and add a test that a
repeated handle resolves once. Cast the PREG_OFFSET_CAPTURE offsets to int to
match getUtf8ByteOffset's signature, and reword the resolver docblock to say
"configured default service" instead of naming bsky.social.
2026-06-13 18:35:30 -03:00
Paulo Castellano
9f0c264c39 Cover mention endpoint fallback and partial resolution
Add tests for the two untested branches of the DID resolution: falling back to
the public AppView when the account's PDS can't resolve a handle, and a post
mixing a resolvable mention (kept as a facet) with an unresolvable one (a 200
without a DID, left as plain text). The prior tests only hit the happy path and
the all-endpoints-fail path via a wildcard that masked the fallback.
2026-06-13 18:21:16 -03:00
Paulo Castellano
409c6f0139 Drop the resolution warning log and fake createRecord via config
Remove the final Log::warning (and the lastError/lastEndpoint tracking that only
fed it) — the per-endpoint debug log already covers diagnosis. Fake the
createRecord endpoint from config('trypost.platforms.bluesky.default_service')
in the new tests instead of hardcoding the host.
2026-06-13 18:19:04 -03:00
Paulo Castellano
2641cc66fe
Merge branch 'main' into fix/bluesky-mention-facet-resolve-did 2026-06-13 18:09:00 -03:00
Paulo Castellano
730cb9d156 Tidy automation backend: run duration, folded migrations, imports
- Add AutomationRun::durationInMilliseconds() as the single source of truth
  for the Invocations list and metrics, replacing the duplicated inline diff.
- Fold the variables and root_run_id columns into their create migrations
  (this branch isn't in production) and drop the standalone alters.
- Import Illuminate\Http\Response (aliased) instead of referencing it inline.
2026-06-13 16:30:32 -03:00
Paulo Castellano
2bd2e72656 Validate webhook payload template is JSON before it can run
A webhook node parses its payload template as JSON before resolving
placeholders, so a template with unquoted {{ }} placeholders or any malformed
JSON could be saved, tested, and activated — only to fail midway through a run.

Reject it up front instead: AutomationConfigValidator is the single source of
truth for per-node config issues (keyed to the field the editor surfaces them
under), enforced on save (field errors), on activate, and before a test run.
The editor mirrors the check to disable Test/Activate with a clear reason, and
the test panel now surfaces the server's message instead of a generic toast.
2026-06-13 16:03:19 -03:00
Paulo Castellano
a129b6b5cd Denormalize automation trigger_type into an indexed column
The scheduler command ran every minute and loaded all active automations,
then filtered by trigger_type in PHP because that value lived buried in the
nodes JSON array — effectively a full-table scan plus a JSON decode per row
each minute, discarding every non-schedule automation.

Derive trigger_type into a real, indexed column on save (recomputed in the
existing saving() hook so it can never drift from nodes) and filter on it in
SQL. Applies to both the schedule firer and the post-trigger dispatcher.
2026-06-13 15:26:54 -03:00
Paulo Castellano
605261e1b8 Back fixed-set automation strings with enums and consts
Replace magic strings across the automation domain with backed PHP enums
(HttpMethod, AuthType, DelayUnit, ScheduleField) and mirrored TS consts
(http-method, auth-type, delay-unit, schedule-field, condition-operator,
publish-mode), plus the existing Condition\Handle / Operator / Publish\Mode.

Also:
- require scheduled_offset via concrete-index required_if instead of
  defaulting to 60 when the publish mode is scheduled
- fail the webhook node explicitly when the resolved url is empty
- localize node failure messages (fetch_rss/http/webhook)
- cast resolver/strtoupper inputs to string so a present-null config value
  degrades gracefully instead of crashing
- list automations with config('app.pagination.default'), drop the perPage param
2026-06-13 15:04:24 -03:00
Paulo Castellano
478cad9f27 Tighten automation conventions: pagination, imports, i18n, enums
- Pagination: drop the perPage override from ListAutomations and the
  hardcoded page size from GetAutomationInvocations; both use
  config('app.pagination.default'). Document the rule in CLAUDE.md.
- Imports: import DomainException / InvalidArgumentException / Throwable
  instead of inline backslash references across the automation actions.
- i18n: move the hardcoded Fetch RSS and HTTP Request failure strings to
  automations.errors.* in all three locales.
- Publish: require scheduled_offset when mode is scheduled (validation)
  and drop the magic 60-minute default in the node. Fixes the required_if
  rules to reference the concrete node index instead of a wildcard that
  never resolved (also repairs the trigger cron rule).
- Condition handles: back the yes/no output handles with a shared
  Condition\Handle enum (PHP) and ConditionHandle const (TS).
2026-06-13 13:59:52 -03:00
Paulo Castellano
37a7a64ff0 Drop the custom-cron schedule option from automations
Remove the "Custom (Cron)" schedule field — too technical for the editor.
The remaining presets (minutes/hours/days/weeks/months) cover the need and
still build the cron string under the hood.

Removed at the root: the ScheduleField.Custom enum case, the custom-cron
input and select option, the schedule_custom_cron type field and its
schedule-summary handling, the backend validation (Rule::in and the
schedule_custom_cron rule), the i18n keys, and the custom round-trip test
case. Existing automations keep firing — the scheduler runs off the stored
cron string, not schedule_field.
2026-06-13 10:06:38 -03:00
Paulo Castellano
31b6544c57 Cover node-run error paths and align webhook failure handling
Wrap the webhook HTTP send in a try/catch so a connection error returns
a clean failed result (reason: request_failed) instead of bubbling up as
a job failure — matching the HTTP request node.

Add tests for the gaps in node-run coverage:
- HTTP request: basic auth, PUT/PATCH/DELETE, non-2xx responses,
  connection exceptions, and an items_path that doesn't resolve to a list.
- Webhook: every HTTP method, header expression resolution, and the new
  connection-failure path.
- Fetch RSS: non-2xx feed responses, malformed XML, items without a
  publish date (skipped), and the link fallback when an item has no guid.
- Delay: unknown unit throws.
- Publish: dry runs don't publish or queue.
2026-06-13 09:56:18 -03:00
Paulo Castellano
3e43da29e0 Add Workflow/Invocations/Metrics/Settings tabs to automations
Split the automation detail screen into four route-based tabs behind a
shared AutomationHeader:

- Workflow: the existing editor canvas.
- Invocations: a paginated, filterable run log with expandable per-node
  detail, a refresh control, and a loading state.
- Metrics: KPI cards, a runs-over-time @unovis chart with locale-aware
  date labels, and a posts-by-platform breakdown over a date range.
- Settings: rename, an activate/pause switch, and a danger-zone delete.

Invocations and Metrics report only real executions via a new
productionRuns scope, so manual test runs (dry or with real data) never
leak into the log or the charts. The now-unused excludingDryRuns scope
is removed.

Generated copy now flows the most-restrictive platform context through
the humanizer too, and the editor guide documents every available
expression grouped by source node.
2026-06-12 19:19:46 -03:00
Paulo Castellano
a09b1f45c2 Structure brand voice and make generated copy platform-aware
Replace free-text brand_tone/brand_voice_notes with a single structured
brand_voice_traits JSON column backed by the BrandVoiceTrait enum, exposed
as choice-chip pills in the brand settings UI and autofillable from a site.
Brand voice and visuals become per-automation toggles on the Generate node.

Unify the image controls into one 0-10 picker (0 = text-only, 1 = single,
2+ = carousel) and feed the generator the most restrictive selected network
so copy fits every platform. Pass that same platform context through the
humanizer pass — extracted into a shared ResolvesPlatformCopyBudget trait —
so the rewrite can no longer drift past the character cap the generator
respected, in both the automation and manual creation flows.

Persist the trigger node's schedule editor fields on save (they were
silently dropped by validated() for lacking validation rules).
2026-06-12 17:09:39 -03:00
Paulo Castellano
448ae73389 Add expression autocomplete, side-panel editor, and richer HTTP fetch
Automations editor:
- {{ }} expression autocomplete in CodeMirror, scoped to the braces and
  graph-aware (suggests only what upstream nodes provide + variables + now);
  migrate the Generate prompt to CodeMirror so it shares the same completions
- Expandable editors: an expand button slides out a side-by-side panel
  (matching the sidebar card), with a minimize control; the inline field
  collapses to a hint while editing in the panel
- Hover-revealed editor toolbar (expand/copy) with styled tooltips so the
  buttons no longer obscure the text while reading
- Beta badge on the Automations sidebar item
- Delete a single connection with Backspace/Delete (edge selection)
- Re-key node config so switching between same-type nodes refreshes the form

HTTP fetch node — cover every JSON response shape:
- Top-level array, object map (items_path=*), array of primitives, and NDJSON
- Key-based dedup via item_key_path (seen-set, FIFO-capped) for feeds without
  dates; first poll records a baseline and emits nothing (date path too)

Fan-out test visibility:
- root_run_id links every forked branch back to the run that started a test,
  so the test panel aggregates all branches instead of one

Fix a few pre-existing type issues (ScheduleData import, padded minute,
optional created_at).
2026-06-12 11:31:58 -03:00
Paulo Castellano
9a692b4608 Enhance automation functionality: Introduce workflow variables and improve node validation
- Added support for workflow variables in automations, allowing users to define reusable values.
- Implemented validation for Generate nodes to ensure intended image counts align with selected accounts.
- Updated automation models and requests to handle new variables, including encryption for sensitive data.
- Enhanced UI to display variables and their management within the automation editor.
- Improved error handling for webhook and HTTP nodes to prevent requests to invalid URLs.
- Refactored various components for better context resolution during automation runs.
2026-06-11 15:47:29 -03:00
Patrick Schrall
b317b8dbb2 Address Copilot review: normalize endpoints, quieter logs, robust tests
- resolveHandleToDid: rtrim trailing slash on the service URL before building
  the request URL and before the `=== $service` auth check (avoids double-slash
  URLs and mis-detected auth).
- Log per-endpoint resolution attempts at debug; emit a single warning only
  after all endpoints are exhausted (less noise during transient outages).
- Tests: fake resolveHandle with a wildcard so the case is isolated from the
  configured service URL; read request payload via $request->data().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 17:37:16 +02:00
Patrick Schrall
eaef12b331 Fix(bluesky): resolve @mention handles to DIDs before building facets
A mention facet (app.bsky.richtext.facet#mention) requires the target's
DID in its `did` field. The publisher put the raw handle there with the
comment "Will be resolved by Bluesky", but Bluesky does not resolve
handles at that point — it rejects the whole record with InvalidRequest,
surfaced to users as "Invalid post data". As a result, every scheduled
post that contained an @mention failed to publish, while posts without a
mention published fine.

Resolve each handle to a DID via com.atproto.identity.resolveHandle
(account PDS first, then the public AppView / bsky.social as fallback),
cache resolutions per post, and use the resolved DID in the facet. If a
handle cannot be resolved, the facet is skipped so the post still
publishes with the @handle as plain text instead of failing entirely.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:18:30 +02:00
Paulo Castellano
4efaa0bf99 Harden automations module: full-post generation, reliable runs, editor UX
Generation
- Generate node now produces the full post (text + AI image + carousel)
  via a shared PostImagePipeline extracted from StreamPostCreation
- Generate config UI mirrors the /posts/create wizard (carousel slide
  count, include-image toggle); drop the decorative format/unsplash keys

Flow correctness
- RSS/HTTP nodes expose named has-items (default) and no-items output
  handles, labeled and colored like the Condition node
- AdvanceAutomationRun records a no_matching_edge terminal instead of
  completing silently; "0 new items" feedback in the test panel
- Manual/test runs no longer persist the production dedup watermark

Run reliability
- Pause truly halts in-flight runs (production only; manual test runs
  always run regardless of automation status)
- ProcessAutomationNode::failed() marks the run failed
- automation:recover-stuck-runs and automation:prune-dry-runs commands

Webhook / HTTP
- Branded User-Agent (config-driven) on outbound webhook + http_request
- Webhook fails on invalid JSON instead of silently sending {}
- HTTP custom headers editor; CodeMirror-based CodeEditor for JSON

Editor UX
- Header Test button only opens the panel; the panel has a Run button
  (saves first) and owns the with-real-data toggle
- Clicking a node closes the test panel and opens its config
- Node cards: max-width + truncate so long URLs don't grow the node
2026-06-10 20:45:01 -03:00
Paulo Castellano
af4d83190e Move automation reads and delete into actions 2026-06-10 18:10:13 -03:00
Paulo Castellano
ab7c7c8bdf Dispatch post-trigger automations asynchronously via job 2026-06-10 17:46:46 -03:00
Paulo Castellano
6d3a375add Add strict_types to automation files and AutomationNodeState factory 2026-06-10 17:26:50 -03:00
Paulo Castellano
21b14f7893 Merge main into feat/automations-module
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.
2026-06-10 17:09:54 -03:00
Paulo Castellano
8b8b59f52e
Merge branch 'main' into fix/linkedin-drop-r_basicprofile 2026-06-10 16:07:52 -03:00
Paulo Castellano
efe49c34de Make AspectRatio the single source for crop math; fill test gaps
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.
2026-06-10 15:52:41 -03:00
Paulo Castellano
ce6ee04883 Bring post platform meta (aspect ratio) to parity across API and MCP
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.
2026-06-10 15:36:47 -03:00
Falconiere Barbosa
a2a7f44c9a
refactor(linkedin): drive OAuth scopes from LINKEDIN_SCOPES env via config
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
2026-06-10 15:04:57 -03:00
Falconiere Barbosa
8e164bda36
refactor(linkedin): dedupe connect-scope test setup into shared closure 2026-06-10 15:04:57 -03:00
Falconiere Barbosa
21bbe96d6b
refactor(linkedin): move OAuth scopes into config/trypost.php platforms
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
2026-06-10 15:04:57 -03:00
Paulo Castellano
4987a31e8a
Merge branch 'main' into fix/linkedin-drop-r_basicprofile 2026-06-10 14:57:40 -03:00
Falconiere Barbosa
410eb9612e fix(linkedin): drop deprecated r_basicprofile from default scopes
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.
2026-06-10 13:51:07 -03:00
Paulo Castellano
82eeaa82d9
Merge branch 'main' into 69-feature-aspect-ratio-selection-for-facebook-posts-like-instagram 2026-06-10 13:14:55 -03:00
Paulo Castellano
cbb73487f2
Merge branch 'main' into fix/tiktok-app-review 2026-06-10 13:02:25 -03:00
Paulo Castellano
5be0abfae3 Cover carousel/multi crop per-ratio and FB multi abort-on-crop-failure
- 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.
2026-06-05 18:45:48 -03:00
Paulo Castellano
1b096f9ede Unify crop storage folder and make IG/FB crop tests symmetric
- 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).
2026-06-05 18:30:17 -03:00
Paulo Castellano
e25b320ad2 Add aspect ratio selection for Facebook posts
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
2026-06-05 18:01:45 -03:00
Paulo Castellano
cca7893e0f Stop persisting instagram_carousel as a content type
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)
2026-06-04 18:10:39 -03:00
Paulo Castellano
ba98f01530 Fix calendar showing 'no content' for every post
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
2026-06-02 09:16:16 -03:00
Paulo Castellano
2f047f2f7e Fix workspace name-only update rejected by required brand fields
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
2026-06-02 08:45:11 -03:00
Paulo Castellano
65afd86824 fix(tiktok): default disable_comment to true when meta omits allow_comments
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.
2026-05-29 10:30:06 -03:00
Paulo Castellano
50477b97f0 fix(tiktok): align UX strings and render processing notice for app review
Match the literal copy the TikTok app reviewer looks for during the
Content Posting API audit, per the Mixpost playbook (the only public
account of how to pass).

- Privacy dropdown placeholder: "Select who can view this post"
- Tooltip on interaction toggles disabled by creator: "Turned off in
  your TikTok account settings"
- Render the "may take a few minutes" notice that was translated but
  never displayed (processing_hint)

Mirror both i18n changes in pt-BR and es. Add a feature test that locks
the literal strings so they cannot drift before the next review cycle.
2026-05-29 10:16:04 -03:00
Paulo Castellano
b23ab0166e feat(automations): implement automation features and UI enhancements
- Added new automation-related routes and controllers for managing automations.
- Introduced automation nodes in the UI with distinct styles and interactions.
- Updated sidebar to include navigation for automations.
- Enhanced post creation logic to support automation metadata.
- Refactored content type and platform enums into types for better type safety.
- Added localization for automation-related terms in English, Spanish, and Portuguese.
- Improved error handling in various components to accommodate new features.
2026-05-24 09:17:19 -03:00