Commit graph

146 commits

Author SHA1 Message Date
Paulo Castellano
3dd4621f57 Limit the $1 first month to a new customer's single workspace
The fixed amount_off coupon only nets $1 for a quantity of one, and the
offer is meant for genuinely new signups. A lapsed account that kept
several workspaces and re-subscribes through onboarding would otherwise
be charged N*price - amount_off (not $1), and a returning customer could
whittle down to one workspace to claim the discount again.

Apply the coupon only when the paid first month is enabled, the account
bills a single workspace, and it has never subscribed before; every other
checkout falls back to allowing promotion codes at the full price.
2026-07-09 15:51:50 -03:00
Paulo Castellano
f36881cb6a Fail loud on a missing coupon and block unpaid extra workspaces
Two hardening fixes for the paid first month:

- FirstMonthCheckoutDiscount throws when the paid first month is enabled
  but STRIPE_FIRST_MONTH_COUPON_ID is unset, instead of silently charging
  every new customer the full price with no discount.
- Guard workspace store() with the same active-subscription check create()
  already applies, so a direct POST can't bootstrap a second billable
  workspace and inflate checkout quantity past the fixed first-month coupon.
2026-07-09 15:37:25 -03:00
Paulo Castellano
c940826031 Charge $1 for the first month instead of a free trial at checkout
A $0 trial only runs a weak card authorization, so invalid cards slip
through and only fail once the real charge fires days later. Applying a
$11-off, duration=once Stripe coupon at checkout charges $1 for real on
the first invoice instead, which validates the card immediately, then
reverts to the full monthly price on the next invoice with no manual
swap needed.
2026-07-09 15:18:00 -03:00
Paulo Castellano
3bc481ac07 Make LogoAttacher honor its swallow-all contract; drop the caller try/catch
LogoAttacher::attach promised in its docblock that any failure — including a
persistence error — is logged and swallowed so the caller need not handle it.
But the persistence block was try/finally with no catch, so a Throwable from
clearMediaCollection/addMediaFromPath escaped. Both call sites (store and
updateSettings) each wrapped the call in an identical try/catch + Log::warning
to compensate — a band-aid duplicated across the controller.

Fix it at the root: the persistence block now catches Throwable, logs it, and
returns false, matching the documented contract. Both controller call sites
collapse to a single attach() line, and the now-unused Log/Throwable imports
are dropped.

Adds LogoAttacherTest covering the success path, the swallowed persistence
failure, a failed fetch, and a rejected mime type.
2026-07-03 20:15:37 -03:00
Paulo Castellano
56d7fa4033
Merge branch 'main' into feat/content_languages 2026-07-03 19:38:37 -03:00
Paulo Castellano
de4d5f3c68 Share a ContentLanguageOption type and close the review's test gaps
- Extract ContentLanguageOption into @/types and use it for availableContentLanguages
  across BrandForm, BrandTab, Brand, Create, and the LanguagePicker options, so the
  englishName field that drives search is visible to TypeScript instead of being
  dropped silently by the pass-through prop types.
- Add BrandAnalyzerTest: assert the language schema enum equals the full 15-language
  set (guards against it shrinking back to a hardcoded subset behind ::fake()) and
  that instructions() lists every code.
- Isolate the "LLM language wins" autofill test by declaring the page as `en` while
  the LLM returns `de`, so it actually proves mergeLlm precedence instead of both
  paths agreeing.
- Cover SetLocale's cookie side effect: the default locale cookie is set on an
  invalid/absent cookie and left untouched for a valid locale.
2026-07-03 19:28:28 -03:00
Paulo Castellano
c9209cb2a9 Replace the boolean isRtl() with a direction() accessor on ContentLanguage
isRtl() existed only to be mapped to an 'rtl'/'ltr' string in SetLocale, so the
boolean was the redundant concept. direction() returns the string the one caller
needs, which drops the null-safe-plus-nested-ternary from the middleware and lets
it resolve the language once with an explicit DEFAULT fallback.
2026-07-03 18:57:09 -03:00
Paulo Castellano
c7be1af124 Detect all 15 content languages on autofill, harden RTL/i18n, and cover the gaps with tests
- Brand-analyzer prompt now lists every supported language instead of only
  en/pt-BR/es, so onboarding autofill can detect the 12 added languages. The
  backtick-formatted list is built in BrandAnalyzer::instructions(), keeping the
  Blade clean and the enum free of prompt presentation.
- Translate the delete-confirmation keyword for el/ja/zh/ar (the four locales
  that still shipped the English "delete").
- Make the language and font comboboxes RTL-correct (logical ms-* instead of
  physical ml-*), and let the language combobox be searched by English name via
  a visually-hidden label (ContentLanguage::options() now exposes englishName).
- Correct the ContentLanguage class docblock: the enum is also the source of
  truth for the UI locales' text direction.

Tests: SetLocale middleware dir/RTL, isRtl and the full 15-language
englishName/label match arms, LLM language detection beyond en/es/pt-BR, and
store-path persistence of a non-default content language plus rejection of an
unsupported one.
2026-07-03 18:52:45 -03:00
Paulo Castellano
c59adb4871 Cover every supported language in ContentLanguage detection tests
Extend the homepage-language detection dataset to assert all 15 supported
languages resolve from their <html lang> subtag, and pin the primary-subtag
matching so a malformed tag ("english") no longer resolves via a two-letter
prefix.
2026-07-03 15:39:35 -03:00
Paulo Castellano
f7ef13c116 Centralize supported content languages in a ContentLanguage enum
The new content-language options were hand-duplicated across request
validation, the UI picker, and homepage detection, while the brand
analyzer's structured-output enum and the AI image prompt's language
name still only knew about en/pt-BR/es. That left autofill unable to
detect the new languages and made image text fall back to English for
them.

Introduce App\Enums\Workspace\ContentLanguage as the single source of
truth and derive every site from it:

- Store/UpdateWorkspaceRequest validate against ContentLanguage::values()
- BrandAnalyzer's language enum uses ContentLanguage::values()
- AiImageClient::languageName() resolves via the enum's englishName()
- HomepageMetaExtractor detects through ContentLanguage::fromHtmlLang()
- BrandForm consumes availableContentLanguages from the backend, like
  availableFonts/availableImageStyles, instead of a hardcoded list

Also fix two labels: nl "Nederlandse" -> "Nederlands", zh -> "中文".
2026-07-03 14:12:25 -03:00
Paulo Castellano
c8f875550a Replace generic token-TTL constant with per-platform Platform::defaultTokenTtlSeconds()
The single LONG_LIVED_TOKEN_TTL_SECONDS constant (Meta 60-day) plus a loose
inline 7200 for X made it unclear which networks each value applied to. Express
the fallback TTL as a per-platform match method instead, matching how the enum
already exposes every other per-network value, so the network->value mapping is
visible in one place: X 2h, Instagram/Threads 60d, everyone else null (they
always return expires_in). Behavior is unchanged.
2026-07-03 13:11:36 -03:00
Paulo Castellano
83ba72ddc6 Stop Meta rate-limit errors from disconnecting Instagram/Threads tokens
TokenRefreshClient classified every non-5xx/429 refresh failure as a dead
token. Meta returns rate-limit (code 4/17) and transient (code 1/2) errors as
HTTP 4xx with type OAuthException, so a throttled proactive refresh was
disconnecting still-valid Instagram/Threads tokens — and the wider 24h/15-min
refresh cadence raised the odds of hitting it.

Classification now keys on error code 190 (the signal the publish exceptions
already use): only a genuine 190 disconnects; every other Meta 4xx is treated as
transient (PlatformUnavailable) and retried next cycle. The same over-broad
type-based check in verifyInstagram/verifyFacebook/verifyThreads is replaced
with the shared Meta\GraphError helper so verify and refresh agree.
2026-07-03 11:07:54 -03:00
Paulo Sérgio Dantas
4e80fd54b7 fix(media): don't 500 when the Unsplash/Giphy key is set to null
`config('services.unsplash.access_key', '')` only falls back to '' when the key
is *absent* — if the env var is present but empty, config returns null, and
assigning null to the `string` property throws a TypeError, taking the whole
request down with a 500 during media search.

Cast to `(string)` so a null/empty key degrades gracefully to '' and the service
returns an empty result set (as it already does for a missing key) instead of
throwing.

Covered by a regression test for both services.
2026-07-01 12:16:15 -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
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
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
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
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
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
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
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
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
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
aacc4390a2 refactor(billing): replace MonthlyCreditsLimit Pennant feature with BillingCycle
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.
2026-06-22 09:55:03 -03:00
Paulo Castellano
0619f2a0f4 refactor: address code review for per-workspace pricing
Bug fix
- Surface the localized "network already connected" message on the
  Facebook/Instagram/InstagramFacebook/LinkedInPage/Threads/YouTube OAuth
  callbacks: catch NetworkAlreadyConnectedException before the generic catch
  so the conflict no longer falls through to a generic error + Log::error.

Scope / dead code
- Remove the orphaned BillingController::checkout() + app.billing.checkout route
  (onboarding starts checkout directly); delete the now-dead DiscordWidget and
  useFeatureAccess composable; prune orphaned i18n keys left by removing the
  plan picker / upgrade dialog / count limits (billing.subscribe.*,
  accounts.limit_reached, workspaces.limit_reached, common.discord.*).
- Drop the unused `plan` prop from the usage page and the unused `label` from
  the onboarding persona payload (labels come from i18n); remove
  Persona::options()/label().

Conventions
- declare(strict_types=1) on the two new migrations.
- Extract autofill validation into AutofillBrandRequest (FormRequest).
- Drop the unused $plan param from AccountPolicy::swapPlan.
- CreateUser: drop the stale config('cashier.trial_days', 7) fallback (now 8).
- Rename LimitEnforcementTest to InvitePermissionTest; use Pest mock() helper in
  WorkspaceQuantitySyncTest; move shared test helpers into Pest.php.
- Memoize BillingCycle window() + subscription lookup.
2026-06-21 21:28:47 -03:00
Paulo Castellano
cbf8fb283c feat: per-workspace pricing, onboarding, and billing overhaul
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.
2026-06-21 20:40:03 -03:00
Paulo Castellano
9f5bacc03f refactor(ai): introduce GeneratorFormat enum and drop stringly-typed format
Replace the 'single'/'carousel' string format threaded through the AI
generation path with a typed GeneratorFormat enum (PostContentGenerator,
PostContentHumanizer, PostContentStreamer, StreamPostCreation,
RunGenerateNode, deriveFormat).

Remove generatorFormat() from AiContentTemplate and the three templates —
it conflated style and format (returning 'tweet_card'). The tweet-card
check now lives on ContentStyle via isTweetCard(), and the humanizer skip
on ContentStyle::humanizes(), so the style declares its own behaviour
instead of callers comparing magic strings.
2026-06-18 11:11:23 -03:00
Paulo Castellano
0574560570 feat(ai-templates): ContentStyle enum as the typed source for the 3 styles 2026-06-17 19:52:38 -03:00
Paulo Castellano
3acaafb81e test(automations): extract inline feed XML into shared fixtures
Move the RSS sample feeds out of inline heredocs/constants in FetchRssNodeTest
into tests/fixtures/feeds/, matching the parser fixtures. Centralize the
feedFixture() loader in tests/Pest.php and use it across the feed tests.
2026-06-16 11:06:46 -03:00
Paulo Castellano
246a159f34 feat(automations): multi-format RSS/Atom feeds with dynamic variables
Replace the RSS-2.0-only SimpleXML parser with SimplePie so the Fetch RSS
node reads Atom 1.0 (YouTube, GitHub, The Verge…) and RSS 2.0 + namespace
extensions (dc:, content:, media:, yt:, itunes:). Each item exposes stable
cross-format aliases (title, link, date, content, author, …) plus every
namespaced field flattened for use as {{ fetched.* }}.

Add a feed-inspection endpoint that discovers a feed's real fields and feeds
them into the editor's expression autocomplete. Allow {{ }} expressions in the
feed URL via a ResolvableUrl rule. Parsing moves to a dedicated FeedParser
service; the fetch keeps the SSRF guard and gains XXE-safe parsing.
2026-06-16 10:56:08 -03:00
Paulo Castellano
65116de859 Register all models in the morph map + lock it with a test
Add the five automation models to Relation::enforceMorphMap(), document the
all-models-must-be-mapped rule in CLAUDE.md, and add MorphMapTest to fail
when any app/Models class is missing from the map.
2026-06-14 16:12:49 -03:00
Paulo Castellano
8e334cd676 Keep past_due subscribers in-app instead of forcing re-subscribe
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.
2026-06-14 15:46:17 -03:00
Paulo Castellano
b9fa8be513 Group Telegram service classes under Services/Social/Telegram 2026-06-14 13:32:03 -03:00
Paulo Castellano
b6a3cb886c Centralize Telegram Bot API URL/token construction in a TelegramApi helper 2026-06-14 12:41:19 -03:00
Paulo Castellano
816d367dbe Add return type to telegram exception test helper 2026-06-13 22:58:04 -03:00
Paulo Castellano
2585d89cb4 Address PR review: connect-status enum, publisher cleanup, fill test gaps 2026-06-13 22:38:23 -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
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
1b2ff23dff Guard expression resolver against malformed-UTF-8 json_encode
resolveVariable() declares a string return type, but json_encode returns
false on malformed UTF-8 (plausible for scraped feed/HTTP payloads),
which throws a TypeError under strict_types and fails the node. Encode
with JSON_PARTIAL_OUTPUT_ON_ERROR and fall back to an empty string.
2026-06-13 10:25:25 -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