Cold review caught a regression from the two previous commits. Instagram and
Threads use long-lived tokens refreshed by EXTENDING the access_token itself
(grant_type=ig_refresh_token / th_refresh_token) — they have no separate
refresh_token and CANNOT be refreshed once expired. The anti-over-rotation rule
("only refresh a token once it's actually expired") is right for rotating
single-use refresh_token platforms but wrong for these: it left IG/Threads
tokens to lapse, after which the extend call fails and the account disconnects
(~every 60 days).
Gate the anti-rotation on the platform's refresh model:
- Platform::extendsAccessTokenOnRefresh() — true for Instagram/Threads.
- SocialAccount::needsProactiveTokenRefresh() — expired for rotating platforms,
OR expiring-soon for extension platforms (restores isTokenExpiringSoon).
- RefreshSocialToken extends (refreshToken) extension-model tokens while still
valid, and verifies (access-token-first) rotating ones.
- All 23 publisher/analytics pre-checks now use needsProactiveTokenRefresh().
Tests: proactive job extends a still-valid Instagram token; a model test covers
the rotating-vs-extension branching; existing X/LinkedIn anti-rotation tests
are unchanged.
Refs #126
Every publisher and analytics service proactively refreshed the token when
it was expired OR merely "expiring soon" (within 15 min), calling
refreshToken() directly. For X (and other single-use-refresh providers)
that rotated a perfectly valid access_token whenever an operation ran in the
token's final 15 minutes — the same needless rotation that breaks the
refresh_token chain and disconnects accounts.
Narrow every pre-check to refresh only when the token is actually expired. A
still-valid token is used as-is; if it expires mid-operation the existing
reactive retry (PublishToSocialPlatform) refreshes and retries.
- Drop `|| is_token_expiring_soon` from all 23 publisher/analytics pre-checks.
- Remove the now-unused `isTokenExpiringSoon` accessor (no references remain
anywhere in the repo).
- The reactive retry path (AbstractLinkedInPublisher::retryWithRefresh) and
the expired-token path are unchanged.
Refs #126
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.
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
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.
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.
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.
Credit allotment is now derived directly from BillingCycle::for($account)->creditAllotment()
instead of a cached Pennant feature, removing the dynamic cache-invalidation footgun
(forgetPlanFeatureCache) that had to be called from every subscription/workspace mutation.
Pricing
- Bill per workspace ($12/mo or $120/yr each); Stripe quantity tracks the
workspace count and syncs on workspace create/delete.
- 2,500 AI credits per workspace, pooled at the account level; monthly reset
on the billing anniversary, annual granted upfront (no rollover).
- One social account per network per workspace; remove all count-based limits
(workspace/social/member) and the legacy plan tiers (single Workspace plan).
Onboarding (cloud only: SELF_HOSTED=false + PostHog)
- Replace the /subscribe plan picker with /onboarding persona selection
(Creator/Freelancer/Startup/Agency/Small business/Other), saved on the user
(users.persona) and mirrored to PostHog, then Stripe Checkout on the monthly
price. 8-day trial so Stripe displays 7.
Billing screen
- Remove the Change Plan dialog (dead with a single plan); add an annual-upgrade
banner for monthly subscribers (swapToYearly).
- Current-plan card shows the workspace count instead of the plan name.
System AI
- Brand analyzer / workspace autofill is always allowed and never debits credits
(system feature, not the user's usage).
Self-hosted (SELF_HOSTED=true) bypasses all billing, credit, limit, network,
and onboarding logic.
A past_due subscription made subscribed() return false (Cashier default),
so EnsureAccountReady redirected to /subscribe — which starts a brand-new
checkout and creates a second subscription. Past-due users already have a
subscription; they only need to update their payment method.
Enable Cashier::keepPastDueSubscriptionsActive() so past_due counts as
active and users keep navigating. Surface a past-due notice in the sidebar
footer linking to the Stripe billing portal (not /subscribe). Both trial
modes (subscription trial / generic trial) are unaffected.
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.
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.
- 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.
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.
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.
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).
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).
- 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.
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.
- 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.
Add a trypost config toggle to switch between card-required checkout trials and no-card signup trials, and wire signup, checkout, access gating, UI copy, and tests to both modes.
Co-authored-by: Cursor <cursoragent@cursor.com>
Revert the no-card signup trial flow so access depends on a Stripe subscription trial started at checkout, preventing app access before a payment method is collected.
Co-authored-by: Cursor <cursoragent@cursor.com>
Use Account::postsCountCacheKey everywhere, avoid findOrFail when syncing
post deletes, dispatch SyncAccountUsage after DeleteWorkspace when enabled,
and add coverage for the new paths.
Co-authored-by: Cursor <cursoragent@cursor.com>
Production incident: a customer's Facebook Page post failed with 'The post
is empty. Please enter a message to share.' (error code 197) and ended up
with a contradictory DB state (status=published + error_message=set).
Three independent bugs were uncovered:
A. FacebookPublisher sends 'message'/'description' as null when the user
posts media without text. Graph API requires the key be omitted, not
null. Fixed in publishSingleImagePost, publishMultiImagePost,
publishVideoPost, publishReel.
B. markAsPublished/markAsFailed leak stale fields across transitions
(a published row could retain error_message from a prior failure,
vice-versa). Both transitions now explicitly clear the opposite
side's fields.
C. status='failed' was editable in the UI and the backend, so users
were re-clicking Publish, generating duplicate failure emails and
the contradictory state from bug B. The frontend isReadOnly check
and the UpdatePost backend guard now treat Published/PartiallyPublished/
Failed/Publishing as terminal. To retry, the user duplicates the post.
11 new tests guarantee these can't regress silently: FB payload shape
per content type, PostPlatform field-clearing on transitions, and the
terminal-status block at the controller level.
Onboarding/lifecycle workflows in PostHog (and downstream tools like SendKit)
need to segment users by how many social accounts they've connected and how
many posts they've created. The existing SyncUser job only re-emitted these
counts on signup and billing changes, so the values went stale the moment a
user did anything meaningful.
This wires up two new paths that refresh the account group automatically:
- SocialAccountObserver (#[ObservedBy] on the model) fires SyncAccountUsage
on created/deleted, covering all 14 OAuth callback paths in one hook.
- SyncUsageOnPostCreated / SyncUsageOnPostDeleted listeners (auto-discovered)
fire SyncAccountUsage on the corresponding events dispatched by CreatePost
and DeletePost.
SyncAccountUsage is the new dedicated job for group properties only
(groupIdentify account + workspace). SyncUser was slimmed to just identify
the user and delegate the group sync, removing the duplicated property
mapping between the two jobs.
All entry points (observer + both listeners) short-circuit when PostHog is
disabled, so self-hosted instances without PostHog configured see zero
queued jobs and zero overhead.
posts_count cache is invalidated before each sync so the job reads fresh
counts from the database instead of stale cached values.
Centralizes 'what date should the UI show as trial end?' on the model.
Returns null when not on trial, the subscription's trial date when on
trial-with-card, or the generic trial date for no-card users.
New signups land on a 7-day generic trial (Cashier trial_ends_at) without
a Stripe customer or subscription. Account is on Starter plan limits during
the trial. After 7 days, EnsureAccountReady redirects to /subscribe per the
existing flow.
- CreateUser sets account.trial_ends_at and plan_id = Starter
- EnsureAccountReady allows access when subscribed() OR onGenericTrial()
- Account::isOnTrial() includes generic trial check
Existing users unaffected: paying users have a subscription;
never-paid users continue redirecting to /subscribe.
Two follow-up fixes from the code review:
1. **Unified lock key for markAsDisconnected / markAsTokenExpired.**
Both methods now use `social_account_status:{id}` instead of
different keys. Prevents the race where `markAsDisconnected` and
`markAsTokenExpired` could run concurrently on the same account
(publish-time vs verify-batch-time), causing overlapping updates and
duplicate notifications.
2. **i18n for notification title/body in markAsTokenExpired and
markAsDisconnected.** Strings were previously hardcoded in English.
Added `notifications.account_disconnected.{title,body}` and
`notifications.account_token_expired.{title,body}` in en, pt-BR, es.
Follows the project convention (e.g. `Mail/PostPublished`) of
concatenating the `@` prefix in PHP before passing the username to
the translation placeholder, instead of putting `@:account` in the
lang file.
Three related fixes for the failure mode where a scheduled post errors out
as 'An unknown X error occurred.' when a social account's refresh_token
was already invalidated by the provider:
1. **PublishToSocialPlatform**: fail-fast when account status is
`TokenExpired`. Previously the job tried to publish, the publisher
internally tried to refresh, the provider rejected the rotated
refresh_token, and the failure surfaced as a generic 'unknown' error
instead of a clear 'reconnect your account' signal.
2. **XPublisher::refreshToken**: when the OAuth endpoint rejects the
refresh_token (typically because it was rotated/revoked at X), log the
raw response and throw `TokenExpiredException` instead of falling
through to `XPublishException::fromApiResponse` which expects the
tweet-API response shape (`type`/`title`/`detail`) and treats
OAuth-style responses (`error`/`error_description`) as 'Unknown'.
3. **SocialAccount::markAsTokenExpired**: dispatch an in-app + email
notification (`Type::AccountDisconnected`) when an account
transitions from `Connected` → `TokenExpired`, mirroring the
existing pattern in `markAsDisconnected`. Wrapped in a lock to
prevent duplicate notifications on concurrent transitions. Accepts an
optional `notify: false` so the batch verifier
(`VerifyWorkspaceConnections`) can suppress per-account
notifications and rely on its summary email.
Core changes:
- Replace Unsplash slide pipeline with gpt-image-2 via Laravel AI SDK.
New AiImageClient builds prompts from a Blade template seeded by the
workspace's ImageStyle enum, content language, brand color (mapped to a
human-readable name via HexColorName helper) and brand description.
- Drop Template B from TemplateImageGenerator: every slide now renders as
Template A (full-bleed photo + bottom gradient + white/grey overlay).
Removes renderTemplateB, roundCorners, blendHex, ensureContrast and the
closing-slide pipeline.
- StreamPostCreation creates the Post directly and dispatches
PostCreationReady with post_id; the wizard kills its preview step and
redirects straight to the post editor on completion. Finalize endpoint
removed.
- New Workspace.image_style enum field with an 8-option visual picker
shared by /workspaces/create and /settings/workspace/brand via a single
BrandForm component (autofill is a prop). 8 sample webp thumbs ship
under public/images/branding/image-styles/.
- Media items gain optional source ('ai'|'unsplash'|'giphy') and
source_meta (recipe needed to regenerate AI images later); the gallery
picker tags Unsplash/Giphy attachments.
- Brand-color autofill: new CssColorFrequencyExtractor parses every
hex/rgb/hsl value in the homepage CSS, clusters perceptually similar
shades in CIE LAB (Delta E 76 < 12), filters neutrals and returns the
most frequent cluster. Solves Tailwind/utility-CSS sites where no
semantic --primary variable is exposed.
- Credits: gpt-image-2 metered at 15 credits/image (low quality default).
- Layout: AuthSplitLayout right column is sticky/h-svh so the form
textarea growth no longer stretches the marketing slider.
- i18n cleanup: localized labels follow the no-em-dash convention.
Class names and method signatures already explain what these classes
do. Project guideline (CLAUDE.md): comments only when the WHY is
non-obvious — implementation details belong in commits, not noise on
top of every class. Kept the one comment that earns it: the (int)
cast in HasUsage::cachedPostCount, which documents the load-bearing
workaround for Laravel's Redis cache numeric optimisation.
Production crashed on every Inertia request after the PostHog branch
landed:
TypeError: App\Models\Account::cachedPostCount(): Return value must
be of type int, string returned at app/Models/Traits/HasUsage.php:80
Root cause: Laravel's RedisStore optimises is_numeric values by storing
them raw (not serialised) so they remain INCR/DECR-able atomically.
The side effect is that an int written via Cache::put comes back as a
string on read. The strict ': int' return type on cachedPostCount then
threw a TypeError.
Local dev and CI used the file/array/database drivers respectively,
which serialise everything blindly and preserve the int type, so the
bug never surfaced before deploy.
Fixes:
- Cast the Cache::remember result to (int) — defensive, survives any
driver-specific behaviour. Documented inline so the cast is not
later removed as redundant.
- Change config/cache.php default from 'database' to 'redis' so local
dev matches prod by default and similar driver-specific bugs surface
before merge instead of after deploy.
- Regression test that seeds the cache with a literal string (mimics
the production Redis read) and asserts cachedPostCount still returns
an int.
Replaces the implicit Account::booted() observer with an explicit
Account::forgetPlanFeatureCache() method called from each plan_id
mutation site (StripeEventListener x3, BillingController x2). Self-hosted
installs naturally never reach any of these callsites — Stripe webhooks
do not fire and the billing controllers redirect to /calendar before any
plan mutation happens — so the Pennant flush is now guaranteed to be a
cloud-only operation.
Adds integration coverage proving the full chain webhook -> plan_id
update -> Pennant flush -> next Feature::value resolves against the new
plan limit.
Posts have no plan-quota gating, so a brief staleness on the count is
acceptable. Avoids the heavy aggregate query on every authenticated
request. Empty-account case skips cache writes entirely.
Wire PostHog identify + dual-group context across the stack so events
land on the right person and account/workspace groups, with counts kept
fresh by Inertia navigations rather than per-domain triggers.
- New `app/Jobs/SyncUserToPostHog.php` (queue `posthog`): centralised
high-level sync — identifies the user and group-identifies their
account + current workspace using `$account->usage()` so the metrics
reuse the same source of truth Inertia ships in shared props.
- `app/Actions/User/CreateUser.php`: dispatches `SyncUserToPostHog` on
signup instead of calling `PostHogService` inline. Keeps the action
fast and routes everything through the queue.
- `app/Listeners/StripeEventListener.php`: webhook now captures
`subscription.created`/`updated`/`cancelled` against the account
owner profile (with `account` group auto-attached) and re-dispatches
`SyncUserToPostHog` so plan/has_active_subscription/is_on_trial
refresh after Stripe state changes.
- `app/Services/PostHogService.php`: `capture()` accepts an optional
`Account` that auto-attaches `$groups.account`, `account_id`, and
`plan` properties. Each public method short-circuits when
`POSTHOG_API_KEY` is unset so self-hosted installs are unaffected.
- `app/Models/Traits/HasUsage.php`: adds `postCount` to the usage
shape (combined `withCount(['socialAccounts','posts'])` query) so
posts count is part of the same payload Inertia already ships.
- `config/horizon.php`: adds `posthog` to `supervisor-1` queues so the
queued PostHog jobs actually drain in production.
- `resources/js/app.ts`: extracts `syncPostHogContext(page)` and calls
it on boot AND on every Inertia navigation, reading the fresh
`usage` props. This:
- Refreshes account group counts (workspaces, social accounts,
posts, members, credits) without per-domain triggers.
- Resolves the workspace-switch case where `setup()` does not
re-run but `navigate` fires with the new `auth.currentWorkspace`.
- Captures the initial `$pageview` so the first page of a session
is no longer dropped.
- `resources/js/components/UserMenuContent.vue`: `posthog.reset()` on
logout so a follow-up login on the same browser doesn't keep events
attributed to the previous user.
- `resources/js/composables/useFeatureAccess.ts`: TS `Usage`
interface gains `postCount`.
- `tests/Feature/Models/HasUsageTraitTest.php`: updated for the new
usage shape. Full suite: 1407 passed.
Hierarchy aligned with the domain model: person = User,
group `account` = billing/plan parent, group `workspace` =
collaboration child (carries `account_id` for drill-down).
- Billing settings page restructured around the design system: dropped
the 280px-label / 1fr-content split for stacked HeadingSmall sections;
current plan rendered as a hero card with display-font name, price,
amber sticker tile, and an inline primary "Change plan" CTA; payment
method consolidated into a single sticker row with a violet card-icon
tile and the manage CTA on the same line; empty payment-method state
handled. Invoices keep the sticker-row treatment.
- Upgrade dialog mirrors Subscribe.vue end-to-end: planTones backgrounds
per slug, ⭐ POPULAR / CURRENT badges absolute-positioned, ink-bordered
rounded-full CTA pill, sticker check icons, "Everything in {plan}"
copy, IconInfoCircle tooltip on credits, yearly/monthly toggle pill
with rotating amber save-months badge. While a request is in flight
every plan button is disabled and the active one shows IconLoader2
spinner.
- Account model gains `displayablePaymentMethod()` returning the
card array used by the UI. Resolves the customer-level default first,
falls back to the first attached payment method (Stripe Checkout trials
anchor the card to the subscription rather than the customer, so the
customer-level lookup returns null even when a card exists).
- i18n: added `billing.subscription.expires_on` and `no_payment_method`
in en/pt-BR/es.
Persists marketing attribution and registration metadata for new users
across the three signup paths (email, Google, GitHub):
- 5 utm_* columns + registration_ip on the users table
- PreservesUtmParameters trait stores incoming utm_* query params on
the register/redirect GET, retrieves them on the POST/callback —
surviving the OAuth round-trip via session
- request()->ip() captured at the controller layer
Adds GitHub as a second OAuth provider:
- GitHubController mirroring the Google one (now renamed from
SocialLoginController for symmetry)
- Settings → Authentication can connect/disconnect GitHub like Google
- Single SocialLogin.vue component replaces the per-provider buttons
on Login/Register, rendering each enabled provider plus a single
"or continue with" divider
UserFactory gains defaults for the new nullable columns so model
strict-mode access in tests doesn't trip.
MediaAttacher had grown into a 180-line class with four private
methods doing different things — download, validation, storage,
and post-state mutation. Two of those (lock-and-merge into the
JSON media column, intersection of allowed media types per
platform) are post concerns, not network concerns.
Move them onto the Post model:
Post::allowedMediaTypes(): array<Type>
The intersection of media types accepted by every enabled
platform. Used to be inlined as MediaAttacher::allowedMediaTypesFor.
Post::appendMedia(array $items): void
Lock-then-merge into the post's media[] JSON column so
concurrent writers don't overwrite each other. Used to be
MediaAttacher::mergeIntoPostMedia.
MediaAttacher now reads cleanly:
attachFromUrls — orchestrate (loop URLs, batch the appendMedia)
attachOne — download → validate type/size → handoff
download — Http::sink + progress abort, returns DTO
Drops to ~120 lines with no nested concerns. The streaming progress
abort uses the same throw-RuntimeException pattern, but now
contained in download() instead of leaking into the orchestrator.
Three things in one move:
1. Centralize per-type size caps in config/trypost.php under media.max_size_mb.
The MediaType enum now reads from there:
MediaType::Image->maxSizeInMb() // 10 (env: MEDIA_IMAGE_MAX_SIZE_MB)
MediaType::Video->maxSizeInMb() // 1024 (env: MEDIA_VIDEO_MAX_SIZE_MB)
Plus convenience helpers maxSizeInBytes() and maxSizeInKb() so callers
don't have to multiply themselves. StoreAssetRequest now uses
MediaType::Video->maxSizeInKb() in its 'max:' rule and mimes derived
from MediaType::{Image,Video}->allowedMimeTypes(). storeChunked
validation moved to the new StoreChunkedAssetRequest FormRequest
(also reads from the enum). MediaAttacher uses
MediaType::Video->maxSizeInBytes() as the streaming-abort threshold
and enforces the per-type cap after MIME resolution.
2. Drop MediaType::Document. We never accepted PDFs anywhere — the
StoreAssetRequest mimes list excluded them, the storeChunked
extension regex excluded them, MediaAttacher excluded them. The only
places that referenced Document were:
- Platform::allowedMediaTypes for LinkedIn/LinkedInPage (declared
but unreachable)
- HasMedia::getMediaType fallback when MIME wasn't image/video/*
Both now cleaned up. HasMedia::getMediaType throws
InvalidArgumentException for unsupported MIMEs instead of silently
returning a fake 'document' type. Platform::LinkedIn now matches
every other social platform: [Image, Video].
3. Add MediaType::fromMime($mime): ?self — replaces the inline mime →
type loop that MediaAttacher used to roll. Returns null for
unsupported MIMEs (caller decides how to react).
Tests:
- MediaTypeTest rewritten for the new shape (no Document, config-driven
sizes, fromMime + size-helper coverage).
- PlatformTest no longer asserts Document on LinkedIn.
- HasMediaTest replaces the 'detects document type' case with one that
asserts the throw on unsupported MIMEs. The 'add media from path'
test now uses real PNG bytes from the fixture.
- AssetControllerTest chunked tests use real PNG bytes and assert 422
(FormRequest unprocessable) for malformed Content-Range headers,
matching the new validation layer.