Commit graph

385 commits

Author SHA1 Message Date
Paulo Castellano
bfbcd642ea feat(instagram): auto-fit story images to 9:16 with blurred background
Story images that aren't 9:16 were clipped by Instagram. They are now
fitted onto a 1080x1920 canvas — the image is contained and a blurred,
darkened copy of itself fills the letterbox gaps, so nothing is cropped
and the background color adapts to the image.

The fit happens at publish time (the hosted copy lives in social-crops/),
and the post editor preview now renders the same blurred-background fit
for stories, so the user sees exactly what will publish. The aspect-ratio
warning is suppressed for story images since they're auto-fitted.

Instagram only — Facebook stories are video-only in our flow.
2026-06-29 14:35:36 -03:00
Paulo Castellano
a63e9ea778 fix(media): treat any download failure (timeout/DNS/refused) as a failed fetch
MediaAttacher::download() only caught RuntimeException, but a connection-level
failure throws Illuminate\Http\Client\ConnectionException (extends Exception, not
RuntimeException) — e.g. a slow/unreachable proxy hitting the 20s timeout. On the
API hosting path that propagated as a 500 (not the promised 422), skipped the
batch rollback (orphaning an already-hosted item), and leaked the temp file.

Catch Throwable so any fetch failure returns null → the caller rejects cleanly
with 422 and rolls back. Also hardens the existing MCP/REST attach-from-url paths
(a timeout there now reports a failed URL instead of 500).
2026-06-29 13:41:47 -03:00
Paulo Castellano
41cb9b02a3 test(api): cover unsupported-type and mixed-batch inline media scenarios
- External URL that downloads (200) but isn't a supported media type → 422,
  nothing persisted (the type-rejection branch, distinct from a download 404).
- Mixed batch: an already-hosted item + an external URL both succeed → both kept
  in order, only the external one creates a Media row.
2026-06-28 21:15:01 -03:00
Paulo Castellano
df891574f7 fix(api): roll back hosted media when an inline url batch partially fails
Final-review follow-ups:
- MediaAttacher::resolveInlineMedia now deletes the media it hosted in this call
  when any item fails, so a mixed [good, bad] batch no longer orphans the good
  item's Media row + file while the request is correctly rejected with 422. Makes
  the create/update media resolution truly all-or-nothing.
- PostMediaRules: keep source/source_meta on both contracts (the API previously
  passed them through with no item rules — don't silently drop them) so the media
  item shape is uniform; only id/path/url differ by contract.
- Make MediaAttacher::fetchToWorkspace private (no external callers).
- Test the partial-batch rollback (no Media, no post persisted).
2026-06-28 20:59:21 -03:00
Paulo Castellano
1242cc7384 refactor(posts): centralize inline media validation in PostMediaRules
The media.* rules were duplicated across the web update request and both API
requests (and diverged: web requires hosted id+path and tracks source; the API
accepts a bare external url it downloads). Pull them into one
App\Support\PostMediaRules::rules(hosted:) — same pattern as PostPlatformMetaRules
— parameterized by contract, so there's a single place to add a media key and the
validated()-strips-unlisted-keys footgun can't drift between entry points.

Behavior is unchanged (each ruleset is reproduced exactly). Web store keeps its
loose 'media' => array (no item rules) and is left out on purpose — adding strict
rules there would change the web create contract.
2026-06-28 20:43:09 -03:00
Paulo Castellano
8d7dcdf6eb refactor(social): trim verbose comments + harden X chunked upload from review
Cold-review follow-ups on the PR:
- Trim the oversized docblocks/inline comments added across the API controller,
  MediaAttacher, Post, the publish job, and the X publisher to one line (keeping
  the @param/@return array-shape annotations).
- XPublisher::chunkedUpload now accepts ?string $mediaCategory and only sends
  media_category when present — getMediaCategory() can return null, so the strict
  string param was a latent TypeError (unreachable on X today, removed anyway).
- Fix MediaAttacher docblocks: the file imports Type as MediaType, so the
  @param array<Type> annotations didn't resolve — now array<MediaType>.
- Tests: cover the failed() job hook genericizing a raw error, and X failing
  cleanly (XPublishException) when media can't be downloaded.
2026-06-28 20:37:04 -03:00
Paulo Castellano
5b26747dde refactor(social): type the deliberate publish exceptions so they keep a clean message
After the failure-email sanitization, any non-SocialPublishException is shown to
the user as a generic line. The deliberate, user-facing throws in the X,
Facebook, Threads, and LinkedIn (+Page) publishers were plain \Exceptions, so
they'd have been genericized too — losing actionable messages like 'Unsupported
media type for Facebook' or 'X posts require either text or media'.

Convert those throws to their platform's SocialPublishException with a vetted
userMessage + category, so the publish failure surfaces a specific, safe message
(and the catch-all's generic line is reserved for genuinely unexpected errors).

Left as plain \Exception on purpose: the download helpers in Bluesky and Mastodon
(caught internally, returned as null — they never reach the email), and the
shared content-length guard in HasSocialHttpClient (no per-platform exception in
the trait, message is path-free, and it's already validated upstream).
2026-06-28 20:16:16 -03:00
Paulo Castellano
08e64ae074 fix(publish): never surface raw internal errors in the failure email
The post-failure email renders each platform's stored error_message verbatim.
On an unexpected publish error the job's catch-all (and the job-failed hook)
stored the raw exception message — for a PHP TypeError that includes the server
file path (.../releases/<id>/app/Services/Social/XPublisher.php on line 130),
which then reached the customer's inbox.

Only our own SocialPublishException carries a vetted, user-facing message.
Every other throwable (engine errors like TypeError, or library exceptions such
as Intervention's decoder errors which embed temp paths) is now replaced with a
generic line; the raw detail stays in the logs. Token-expired and rescheduled
paths are unchanged.

Note: publishers that still throw plain \Exception for user-facing reasons
(some X/Facebook/Threads/LinkedIn cases) will now show the generic message for
those; converting them to typed SocialPublishExceptions to restore specific
copy is a worthwhile follow-up.
2026-06-28 20:02:35 -03:00
Paulo Castellano
4417aa99ac fix(x): recover a missing media MIME from the downloaded bytes
A post created via the API with bare external-URL media (no mime_type, no
path to infer one from) reached XPublisher::getMediaCategory() with a null
$mimeType — whose parameter is typed string — throwing a TypeError. The
TypeError's message embeds the server file path, which then surfaced verbatim
in the user's failure email.

X downloads the media bytes anyway, so recover the MIME from the downloaded
file when the item carries none, and fail with a clean XPublishException
(MediaFormat) rather than a TypeError when it still can't be determined. Also
drive the image/video decision off the resolved MIME so sniffed images are
still optimized.

Audited every publisher: X was the only one whose strict mime path was
reachable with null (no isImage() gate + no path to backfill from). Bluesky's
uploadBlob is also strictly typed but is guarded by an isImage() check plus the
DTO's extension-based MIME backfill; the pull-from-URL publishers classify via
nullable helpers and fail cleanly; Pinterest/Mastodon already sniff; YouTube
and Facebook video fall back to video/mp4.
2026-06-28 19:29:14 -03:00
Paulo Castellano
81d43c30f4 fix(api): download and host external media URLs on post create/update
The public REST API accepted inline post media as a free-form array and stored
it verbatim, so a client could create/update a post whose media was a bare
external URL we never hosted. Publishing then depended on that third-party URL
staying alive — when it 404'd (e.g. an image proxy), the post failed across
platforms (Facebook 'unsupported media type', X 'HTTP 404', Instagram 'could
not fetch media').

Inline media URLs on create/update now go through the same download + MIME-
validate + host path as the attach-from-url endpoint (MediaAttacher), so the
stored media always points at our own storage. Items already hosted (carrying a
path) pass through untouched. If any URL can't be fetched the request is
rejected with 422 and nothing is persisted, so a post is never created with
broken media. MCP and the web flow were already safe and are unchanged.

- MediaAttacher: extract fetchToWorkspace() + add resolveInlineMedia()
- Post::allowedMediaTypesFor() so the create flow can compute allowed types
  without a persisted post
- API Store/UpdatePostRequest: media.* item rules (mirroring the web; prevents
  validated() from stripping hosted-item keys)
- PostController store()/update(): host external media before persisting
2026-06-28 17:28:05 -03:00
Paulo Castellano
ffa4377615 test(tiktok): fake against the config base URL across all TikTok test files
Convert TikTokCreatorInfoTest and TikTokPublishExceptionTest to fake against
config('trypost.platforms.tiktok.api') instead of the hardcoded host, matching
the TikTokPublisherTest cleanup. TikTokControllerTest is left as-is: its only
TikTok URL is a Socialite authorize stub, which has no config counterpart.
2026-06-26 13:13:15 -03:00
Paulo Castellano
5df854092b test(tiktok): cover cleanup edge cases and fake against the config base URL
- Multi-image carousel: only the oversized photo is resized, order preserved,
  the compliant one passes through untouched.
- Cleanup runs even when publishing fails (no orphaned derivative).
- A throwing Storage::delete does not turn a successful publish into a failure.
- Replace hardcoded https://open.tiktokapis.com/v2 with
  config('trypost.platforms.tiktok.api') so the fakes can't drift from the
  configured (overridable) host.
2026-06-26 13:08:18 -03:00
Paulo Castellano
0829814d8b test(tiktok): cover download-failure path and assert TikTok-specific optimization
Adds a test for the resize download-failure branch, and tightens the resize
mocks to assert optimization is requested for Platform::TikTok specifically.
2026-06-26 12:42:39 -03:00
Paulo Castellano
13e4d88b71 test(tiktok): cover MediaItem dimensions and platform max-width helpers
Direct unit tests for the new MediaItem::width()/height() accessors and
MediaOptimizer::maxWidthForPlatform(), plus drop a redundant inline comment.
2026-06-26 12:31:46 -03:00
Paulo Castellano
c5fca040a9 fix(tiktok): resize oversized photos before PULL_FROM_URL
TikTok photo posts hand the platform our media URLs and let it pull the
bytes, so they never went through MediaOptimizer like the upload-based
publishers do. An image wider than 1080px reached TikTok untouched and was
rejected with picture_size_check_failed ("Image dimensions exceed limits").

Photos within spec still pass through unchanged. Oversized ones (or images
with unknown dimensions) are downloaded, resized to TikTok's 1080px spec,
hosted on the public disk, and that URL is handed to TikTok instead. The
derivative is pruned once TikTok has pulled it.

Reuses the same hosted-derivative pattern Instagram/Threads already use for
aspect-ratio cropping; the TikTok max_width config was previously dead code.
2026-06-26 12:23:56 -03:00
Paulo Castellano
317788eb37 fix(social): apply chunked-upload memory fix to LinkedIn and YouTube
The same leak audited in the X publisher exists wherever a video is streamed
through a per-chunk request loop. Reproduced with the reported 49MB file:
LinkedIn's plain Http::withToken() chunk loop grew to ~146MB (and it allows
videos up to 5GB). Centralize the fix as freeChunkMemory() on the shared
trait and call it (after unset) in the X, LinkedIn and YouTube loops so memory
stays flat.

Audited and left alone: TikTok and Instagram pull from a video_url (no local
upload); Facebook and Bluesky stream the whole file in a single request.

Adds a LinkedIn multi-chunk video test.
2026-06-25 21:38:52 -03:00
Paulo Castellano
5b065ce121 fix(x): stop chunked video upload from exhausting memory
The APPEND loop sent each 1MB segment without releasing it: the chunk plus
its multipart copies and the Guzzle/retry object graph were kept alive by
reference cycles the GC doesn't reclaim mid-loop, so memory grew ~4MB per
segment. A 49MB video peaked at ~242MB locally and crossed the 512MB limit
in production. Freeing the segment and forcing cycle collection each
iteration keeps memory flat (~50MB regardless of video size).

Also harden socialHttp()'s retry predicate: it read $exception->response
directly, which fatals on exceptions without a response (e.g. a
ConnectionException during a long upload), masking the real error.
2026-06-25 21:26:15 -03:00
Paulo Castellano
2525d834b0 test(onboarding): cover the full flow; drop goal exclusivity rule
- add an end-to-end walk (account gate -> persona -> goals -> connect ->
  Stripe) and a self-hosted bypass test, plus the connect no-workspace and
  just-exploring-saved cases
- drop the just_exploring exclusivity: the backend now saves any valid goal
  combination (the front-end still clears siblings as a UX nicety), removing
  the withValidator rule, the unused Goal::isExclusive(), and the now-unused
  goals_exclusive copy
2026-06-25 21:01:46 -03:00
Paulo Castellano
afc4c7a80b feat(onboarding): add goal step after persona
After picking who they are, users now pick what they want to achieve with
TryPost. A multi-select goal step (12 options + an exclusive "just exploring"
and "something else") sits between the persona step and connect, mirroring the
persona screen's style.

The goals persist to a json column on users and are mirrored to PostHog on
identify (onboarding_goals array plus a boolean per goal), so campaigns can be
cross-tabbed against the intent they actually attracted. connect now requires
both a persona and at least one goal; persona store advances to the goal step.

Options are grounded in TryPost's real capabilities (publishing, AI content,
brand voice, automation via API/MCP, collaboration, analytics) and copy is
localized in en/es/pt-BR.
2026-06-25 20:49:04 -03:00
Paulo Castellano
27151bd925 fix(pinterest,youtube): default video cover and gate empty YouTube posts
Pinterest video pins failed with "provide cover_image_url ... or
cover_image_key_frame_time" because no cover was sent. Default to
cover_image_key_frame_time 0 (first frame) when the user provides no
cover_image_url — verified against Pinterest's OpenAPI spec (integer
seconds, minimum 0, falls back to the last frame past the duration).

YouTube Shorts derive their required title from the post content, so the
publisher rejects an empty post — but the editor still let it be scheduled.
Gate it in compliance: a youtube_short with empty content is now flagged
(requires_text) so it can't be scheduled, instead of failing at publish.
2026-06-25 19:59:51 -03:00
Paulo Castellano
49c77406a0 fix(linkedin): enforce carousel cap and fail loudly on processing timeout
- publishCarousel now caps images at the platform max so the API/MCP paths
  can't bypass the 10-image limit the UI enforces
- waitForProcessing throws on timeout instead of posting against an asset
  still being processed; poll attempts/interval extracted to overridable
  protected methods for testability
- scope EnsureHasWorkspace to connect entry points + disconnect only;
  session-driven OAuth callbacks/selects stay ungated so a missing current
  workspace can't HTML-redirect the popup instead of closing it cleanly
2026-06-25 15:34:03 -03:00
Paulo Castellano
12a1baa757 refactor(social): address PR review follow-ups
- DeleteWorkspace now reassigns affected users to another workspace they
  belong to (instead of nulling current_workspace_id), so deleting the
  current workspace while owning others no longer bounces to "create".
- Remove the dead LINKEDIN_PAGE_CLIENT_REDIRECT env (the unified flow uses
  a single callback; config no longer reads a page redirect).
- useOAuthPopup uses the Wayfinder connect helpers per platform instead of
  hardcoding /connect/{platform}.
- Unify the Inertia\Response alias in Mastodon/Bluesky controllers to
  InertiaResponse, matching the other connect controllers.
- Add tests: workspace reassignment on delete, and independent refresh of
  a linkedin row vs its linkedin-page sibling.
2026-06-25 15:01:23 -03:00
Paulo Castellano
dba6346226 refactor(auth): split workspace gate into EnsureHasWorkspace middleware
EnsureAccountReady bundled a subscription gate (redirects to onboarding,
SaaS only) with a workspace gate (redirects to workspace creation). The
connect routes can't sit behind it because connecting/disconnecting
happens during onboarding, before a subscription exists.

Split the workspace gate into a standalone EnsureHasWorkspace middleware:

- EnsureAccountReady is now subscription-only.
- EnsureHasWorkspace redirects to workspace creation when there is no
  current workspace, in both SaaS and self-hosted modes.
- The social connect group gains EnsureHasWorkspace; the main app group
  gains it alongside EnsureAccountReady (listed after it, so the
  subscription gate still runs first — no custom middleware priority).
- The repeated `if (! $workspace) redirect()` guard is removed from the
  connect/store/authorize/disconnect/index/toggle handlers, and their
  return types are tightened (no more dangling RedirectResponse).

LinkedIn connect's no-workspace path changes from a popup callback to the
same redirect as the other platforms.
2026-06-25 14:30:15 -03:00
Paulo Castellano
cd2798fd8d refactor(social): use native Inertia for all connect popup flows
Replace the per-platform native form POST + manual CSRF + JSON/Blade
popup callback with a single Inertia mechanism:

- popupCallback() renders the accounts/PopupCallback Inertia page (notifies
  the opener + closes the popup) for both the GET OAuth callbacks and the
  selection submits. Drops the auth.social-callback Blade view, the
  expectsJson JSON branch, and useHttp/useSocialConnect on the frontend.
- Selection/credential pages (LinkedIn, Facebook, Instagram, Bluesky,
  Mastodon) use Inertia useForm: automatic CSRF + native validation errors.
- Unify the three selection screens on one row + View/Choose layout; the
  LinkedIn company tag now uses a building icon.
- Bluesky auth failures throw ValidationException (422 for XHR, redirect
  back with errors otherwise).

Controller tests updated from assertViewIs/assertViewHas to assertInertia.
2026-06-25 13:54:16 -03:00
Paulo Castellano
10b8579079 fix(linkedin): cap image count at 10 to match platform standard
LinkedIn was the only multi-image platform allowing 20 images; align
maxMediaCount/maxImages/maxFiles to 10, consistent with Facebook,
Instagram and Threads. The AI generation cap (MAX_GENERATED_IMAGES)
was already 10 and is unaffected.
2026-06-25 11:45:11 -03:00
Paulo Castellano
65f66c8225 test(media): cover Type classification branches and MediaItem detection
Add direct coverage for the centralized media-type logic: every legacy image/video extension arm of Type::fromExtension, and a new MediaItem DataTransferObject test (fromArray's extension->mime backfill — previously the only zero-coverage branch — plus is* delegation and the null-mime extension fallback). Locks the broad classification lists so a dropped format fails a test.
2026-06-25 11:10:57 -03:00
Paulo Castellano
54fcaa7263 refactor(media): centralize image/video/document detection in Media\Type
Media-type detection was duplicated across Media, MediaItem (byte-identical copies), HasMedia::getMediaType, the ContentTypeCompatibleWithMedia rule, and six publishers — each hardcoding MIME prefixes and divergent extension lists (Media's video list even had avi/webm/mkv, contradicting the Type enum's mp4/mov).

Add classify(), fromExtension(), and isGif() to Media\Type as the single source for 'what kind is this?' (broad classification), distinct from fromMime()/allowedMimeTypes() (the strict upload allow-list). Every detector now delegates to the enum; the broad extension lists and MIME prefixes live only there. Behavior-preserving (full suite green); adds direct tests for classify/fromExtension/isGif.
2026-06-25 10:57:57 -03:00
Paulo Castellano
6a46bee469 refactor(linkedin): extract shared publisher base and fix multi-image carousel
The member and company-page publishers were ~95% identical (~1200 lines). Extract AbstractLinkedInPublisher holding the whole pipeline (media-inferred dispatch, post/carousel/document payloads, image/video/document upload, processing polling, error handling); each concrete publisher now only declares its platform, author URN, and (for pages) the post URL. ~640 duplicated lines removed, single source of truth.

Fix: multi-image posts sent each image URN under 'media', which LinkedIn's multiImage schema rejects with a 422 (unrecognized field 'media' / required 'id' missing). Send the URN under 'id'. Add assertions locking the 'id' field on both carousel tests, a company-page multi-image test that was missing, and a fail-fast assertion that a missing org id makes no HTTP call.
2026-06-25 10:17:02 -03:00
Paulo Castellano
6184807aba refactor(linkedin): drop now-unused Platform::enabled() helper
The accounts grid and onboarding both build their connect-card list from cases()->filter(isConnectable()) now, leaving the static enabled() helper without any production caller. Remove it and the two unit tests that only exercised it.
2026-06-24 21:39:33 -03:00
Paulo Castellano
dec2bc028a fix(linkedin): show connected company page on the accounts and onboarding grids
The unified LinkedIn card grouped connected accounts by looking the platform up in the rendered card list, but linkedin-page is no longer a card — so an org-only connection fell through to its own network key and the card showed 'Connect' for an already-connected page, with no way to disconnect or reconnect. Expose each account's network on SocialAccountResource and group by account.network instead.

Onboarding still built its connect grid from SocialPlatform::enabled() instead of isConnectable(), so it rendered a standalone LinkedIn Page card with a missing logo whose connect button hit the deleted connect/linkedin-page route (404). Filter by isConnectable() to match the accounts page.
2026-06-24 21:23:27 -03:00
Paulo Castellano
d84666360a refactor(linkedin): infer post format from media + unify account connection
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.
2026-06-24 21:05:09 -03:00
Paulo Castellano
051c797cc1 test(linkedin): end-to-end document publish + harden title resolution
- 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
2026-06-24 17:45:29 -03:00
Paulo Castellano
c339f8cf28 fix(linkedin): align PDF compatibility across editor, MCP schedule, and tests
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
2026-06-24 17:36:39 -03:00
Paulo Castellano
14e44538a7 feat(linkedin): enforce PDF/media compatibility on API and MCP
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
2026-06-24 17:08:48 -03:00
Paulo Castellano
7388313f5c feat(linkedin): support PDF document (carousel) posts
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.
2026-06-24 16:32:27 -03:00
Paulo Castellano
ea2d3d86c8 feat(onboarding): add Marketer and Online store personas
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.
2026-06-24 09:31:33 -03:00
Paulo Castellano
deea4564e2 feat(onboarding): add Developer persona option
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.
2026-06-23 16:19:58 -03:00
Paulo Castellano
beea8a5f97 feat(posts): block combining an image and a video in one post
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.
2026-06-23 14:30:38 -03:00
Paulo Castellano
7e28d17d2c refactor(bluesky): tidy video upload, correct formats, bound polling
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.
2026-06-23 14:30:29 -03:00
Patrick Schrall
7f648f8d4d fix(bluesky): harden video upload per review + retry transient transcode failures
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>
2026-06-23 08:32:27 +02:00
Patrick Schrall
43de7372a6 feat(bluesky): support video upload and embedding
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>
2026-06-23 08:00:33 +02:00
Paulo Castellano
dc29d4dd48 fix(permissions): address code-review findings
- 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
2026-06-22 18:00:36 -03:00
Paulo Castellano
1c9ab462d0 feat(permissions): viewers review drafts in a read-only editor; lock /accounts to admins
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.
2026-06-22 17:04:09 -03:00
Paulo Castellano
8b52ea2082 fix(permissions): viewers open posts read-only instead of hitting the editor
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
2026-06-22 16:41:27 -03:00
Paulo Castellano
5bb39da598 fix(permissions): enforce workspace roles across backend and UI
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.
2026-06-22 16:31:54 -03:00
Paulo Castellano
21e45b4847 chore: remove legacy plan tiers (starter/plus/pro/max)
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.
2026-06-22 15:31:32 -03:00
Paulo Castellano
f572341e36 fix(members): block self role-change/removal and lock role rules with tests
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).
2026-06-22 14:41:11 -03:00
Paulo Castellano
6c47bac1b8 fix(members): preserve invited role on accept and surface viewer in role menu
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.
2026-06-22 14:24:19 -03:00
Paulo Castellano
3e15144399 chore: remove unused Pennant package and dead user-timezone handling
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.
2026-06-22 13:37:38 -03:00
Paulo Castellano
7cc88f1036 fix: address PR review findings
- 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.
2026-06-22 12:53:13 -03:00