Commit graph

215 commits

Author SHA1 Message Date
Paulo Castellano
acdc78f8bd refactor: standardize UI components, modernize localization structure, and remove legacy appearance management middleware. 2026-05-06 14:26:18 -03:00
Paulo Castellano
96e1aa36b6 feat: GTM dataLayer context + checkout/purchase events
Backend exposes account and plan as proper Inertia resources so any
page can read them off shared props:

- AuthAccountResource — id, name, created_at
- AuthPlanResource — id, slug, name, interval (derived from the active
  Cashier subscription's stripe_price)

Both wired into HandleInertiaRequests::share so `auth.account` and
`auth.plan` are available everywhere.

Frontend pushes app + identity context to GTM's dataLayer on every
page load via initializeDataLayer (resources/js/datalayer.ts), and
useTracking gets begin_checkout/purchase wired in the billing flow.

The pushes are no-ops without GTM configured (the array still exists
in memory, nothing reads it). Self-hosted instances without GTM_ID
incur zero error noise. The GTM partials in app.blade.php were
already conditional on `config('services.gtm.id')`.

Processing.vue polls `auth` alongside `subscriptionActive` so
auth.plan.interval is fresh once the Stripe webhook creates the local
Subscription row (it doesn't exist yet at the initial render). The
watch fires only on the false → true transition; an onMounted
fallback redirects users that land on the page with an already-active
subscription without re-firing trackPurchase.
2026-05-06 10:14:17 -03:00
Paulo Castellano
e3acd1bb4b revert: keep one OAuth callback URL per provider
Drops the dedicated /settings/authentication/providers/{provider}/callback
route added in the previous refactor — registering a second callback URL
in each OAuth app is more ops cost than the trade is worth.

Back to one callback URL per provider, with a small `Auth::check()`
branch in the auth controllers' callbacks. The check is safe because
the redirects that initiate the round-trip enforce the right
middleware (signup/login is `guest`-only, connect is `auth`-only),
so the auth state at callback time matches the flow's intent.
2026-05-04 19:42:13 -03:00
Paulo Castellano
79f800f858 refactor: dedicated callback URL for connect-from-settings flow
Splits the OAuth connect flow off entirely from signup/login so each
route has a single responsibility.

- routes/auth.php returns to its original state — redirect + callback
  both back inside the `guest` group.
- routes/app.php gains a paired callback route at
  /settings/authentication/providers/{provider}/callback.
- AuthenticationController::connectProvider /
  connectProviderCallback override Socialite's redirectUrl so the
  round-trip stays on the connect-flow URL. The Auth::check() branch
  in the auth controllers is gone.

The OAuth apps in Google Cloud and GitHub Developer Settings need the
new callback URL registered alongside the existing one — documented in
.env.example.
2026-05-04 19:30:16 -03:00
Paulo Castellano
d89f6ceede fix: let authenticated users connect Google/GitHub from Settings
The Connect button on /settings/authentication pointed at the
auth.{provider}.redirect routes that live behind `guest` middleware,
so authenticated users were bounced to /app/home before reaching
Socialite. The OAuth callback also needed to handle two flows
(signup/login vs link to current user) but had no branch for the
second case — meaning a different-email GitHub account would have
been registered as a new user, logging the original session out.

Splits the flows by intent:

- New `app.authentication.connect-provider` route in the auth group,
  handled by the settings controller (where it sits next to
  disconnect-provider). Replaces the OAuth signup link as the
  Connect button's target.
- Auth callbacks moved out of the guest group (still one URL per
  provider, since OAuth apps only register one) and gain a single
  Auth::check() branch that calls connectToCurrentUser().
- connectToCurrentUser() rejects if the provider id already belongs
  to a different user; otherwise sets it on the current user and
  redirects back to settings with a flash message.
2026-05-04 19:22:09 -03:00
Paulo Castellano
1bf0bbc9ef
Merge branch 'main' into feature/capture-signup-utms 2026-05-04 19:02:49 -03:00
Paulo Castellano
2e9e0f716b feat: capture signup UTMs/IP and add GitHub OAuth login
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.
2026-05-04 18:42:25 -03:00
Paulo Castellano
24a8786cba feat: add multipart media upload endpoint and split URL flow
Adds POST /api/posts/{post}/media for direct file (multipart) upload
and renames the existing URL-based flow to /api/posts/{post}/media/from-url
so the path matches HTTP semantics (POST <resource>/media expects a file
body, not JSON URLs).

The multipart action validates type against the post's enabled platforms
(image rejected on TikTok-only posts), enforces per-type size caps, and
reuses Workspace::addMedia + Post::appendMedia. URL-based attaching is
unchanged behaviorally — only the route name and controller method are
renamed for symmetry. The MCP AttachMediaFromUrlTool was already named
correctly and needs no changes; binary upload via MCP is a host-protocol
limitation that no MCP server (including Postiz) supports.
2026-05-04 18:00:03 -03:00
Paulo Castellano
9cf16ca0c4 refactor: use mime_content_type to reliably detect MIME from file contents instead of relying on unreliable Content-Type headers 2026-05-04 15:22:11 -03:00
Paulo Castellano
335180d7a1 refactor: move post-state concerns out of MediaAttacher
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.
2026-05-04 15:17:48 -03:00
Paulo Castellano
ffdedfe29d feat: keep MOV in video allow-list, drop only WebM
Previous attempt dropped both video/quicktime and video/webm citing
narrow platform support. Re-investigated:

- Modern .mov files (iPhone recordings, screen captures) are ISO BMFF
  containers — the same format MP4 uses. Social platforms decode them
  like MP4 even when PHP's mime_content_type() reports
  'video/quicktime'. Postiz quietly accepts MOV via this same trick:
  their file-type lib detects the magic bytes as 'video/mp4' and
  renames the upload before storage.

- WebM is genuinely incompatible (Matroska + VP8/VP9 stack) — X / IG /
  TikTok / FB / Pinterest / Bluesky / Threads all reject it.

So allow MOV, keep WebM out. Rejecting MOV would force every iPhone
user to transcode before uploading — real UX cost for no gain since
the publishers handle it.
2026-05-04 15:12:32 -03:00
Paulo Castellano
332513e360 refactor: replace regex/preg_match with sscanf + ends_with in chunked upload
Two regex pieces in StoreChunkedAssetRequest, both replaced by more
fitting tools:

1. Parsing 'Content-Range' header — preg_match with capturing groups
   and $matches[1..3] indexing replaced by sscanf, which is
   purpose-built for parsing structured strings with a known format:

       $parsed = sscanf($header, 'bytes %d-%d/%d') ?: [];
       $start = $parsed[0] ?? null;

   No regex, no $matches array, no manual int casts (sscanf returns ints
   for %d).

2. Validating filename suffix — regex:/\.(jpe?g|png|gif|webp|mp4)$/i
   replaced by Laravel's built-in 'ends_with' rule, with extensions
   derived from the MediaType enum:

       $allowedSuffixes = collect([Image, Video])
           ->flatMap(fn (MediaType $t) => $t->extensions())
           ->map(fn ($ext) => '.'.$ext)
           ->all();

   For the case-insensitive part, normalize the filename to lowercase
   in prepareForValidation so 'IMG_1234.JPG' matches '.jpg' naturally.

Added MediaType::extensions() — single source of truth for filename
suffixes (jpg/jpeg/png/gif/webp + mp4/mov/webm). Mirrors
allowedMimeTypes() for callers that validate by name instead of MIME.
2026-05-04 14:59:57 -03:00
Paulo Castellano
770266d36b refactor: centralize media size limits in config + drop Document type
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.
2026-05-04 14:54:47 -03:00
Paulo Castellano
ac361349da refactor: extract attach-media validation into a FormRequest
Project convention is one FormRequest per endpoint
(Api/Post/StorePostRequest, UpdatePostRequest, etc.) — the inline
$request->validate() in attachMedia was the only outlier in this
controller. Extracted to Api/Post/AttachMediaRequest with the same
rules:

    'urls'   => ['required', 'array', 'min:1', 'max:10'],
    'urls.*' => ['url:http,https', 'active_url'],

Controller signature is now AttachMediaRequest $request — Laravel
binds + validates before the action runs, same pattern as store/update.
2026-05-04 14:37:49 -03:00
Paulo Castellano
4892ee75a5 refactor: move URL validation to the request layer with active_url
The MediaAttacher used to roll its own SSRF guard with DNS resolution
and a static fakeUrlSafety() flag for tests. Validating URLs is a
request-layer concern, not a service-layer one. Laravel ships
'active_url' which does the same DNS resolvability check via
dns_get_record — applying it at the FormRequest / MCP validate() level
catches dead URLs upfront with a proper 422 instead of letting the
download silently fail.

- Replace the inline 'urls.*' => ['url:http,https'] rule with
  ['url:http,https', 'active_url'] in both Api/PostController::attachMedia
  and Mcp/Tools/Post/AttachMediaFromUrlTool.
- Drop isUrlSafe(), fakeUrlSafety(), resetUrlSafety(), $skipUrlSafety
  from MediaAttacher. The remaining defenses (Http::sink streaming +
  progress abort at MAX_BYTES, allow_redirects: false, MIME allowlist)
  cover the operational concerns.
- Restore tests/TestCase to the original setUp — no SSRF bypass needed
  anymore because active_url is satisfied by the test hosts.
- Swap synthetic test hosts (cdn.example.com / evil.example.com) for
  example.com / example.org. Both are RFC-reserved AND have stable A
  records, so active_url accepts them while Http::fake() still
  intercepts the actual request.

For SSRF defense beyond 'active_url' (which doesn't block private IPs),
trypost relies on production network egress controls. Open-source
self-hosters who run without a firewall accept the corresponding risk;
that's a deployment concern, not a request validation concern.
2026-05-04 14:35:28 -03:00
Paulo Castellano
bc581f54f2 refactor: read allowed MIMEs from MediaType enum in MediaAttacher
The enum already has `allowedMimeTypes()` — it's the single source of
truth for what each media type accepts (jpeg/png/gif/webp for image,
mp4/quicktime/webm for video). MediaAttacher was duplicating the same
arrays as ALLOWED_IMAGE_MIMES / ALLOWED_VIDEO_MIMES constants, which
meant any change to the supported MIMEs needed two edits.

Drop the constants. `resolveType()` now walks
`[MediaType::Image, MediaType::Video]` and asks each type whether the
MIME is on its allow-list. Document type is intentionally excluded so
PDFs aren't accepted via URL fetch.

Note on size: kept MediaAttacher's own MAX_BYTES = 50 MB (separate from
the enum's per-type caps). URL fetches have different operational
constraints than direct uploads (bandwidth, timeout, unbounded user
input) — a smaller cap here is intentional.

Out of scope but worth a follow-up: `StoreAssetRequest` and
`storeChunked` are also drifted (web only accepts video/mp4 today,
the enum + MediaAttacher accept mov/webm too). They should also read
from the enum eventually.
2026-05-04 14:26:36 -03:00
Paulo Castellano
6ae48f014f fix: persist morph map alias on AI-generated Media rows
Both PostAiCreateController::createMediaItem and
PostTemplateController::createMediaItem were doing:

    $media = new Media([...]);
    $media->mediable_type = Workspace::class;   // ← FQCN literal
    $media->mediable_id = $workspace->id;
    $media->save();

Assigning Workspace::class directly bypasses the morphMap configured in
AppServiceProvider, so rows ended up with mediable_type =
'App\\Models\\Workspace' instead of the alias 'workspace'. Other queries
that pivot through the morphMap (e.g. $workspace->media) lose those
records on hosts where the FQCN doesn't match the alias.

Switch both to the relationship form:

    $media = $workspace->media()->create([...]);

Laravel fills mediable_type via the morphMap, producing 'workspace'.
This is the same pattern AssetController and the HasMedia trait already
use; these two AI controllers were the only outliers (`grep -rn
'mediable_type =' app/` confirms).

Added tests/Unit/MediaPolymorphTest as a regression — asserts the
created row's mediable_type is the alias and that the relationship
resolves back to the workspace.
2026-05-04 14:14:14 -03:00
Paulo Castellano
c3b8af7189 refactor: collapse media-attach helpers back into MediaAttacher
The previous split (MediaAttacher + MediaDownloader + UrlSafetyGuard
interface + DnsUrlSafetyGuard impl + provider binding) was over-engineered
for one production implementation. Roll it all back into a single
MediaAttacher class and delegate to the existing `Workspace::addMediaFromPath`
helper for storage + Media row creation (the same path the web upload
flow uses).

What's gone:
- app/Services/Post/MediaDownloader.php
- app/Services/Post/UrlSafetyGuard.php (interface)
- app/Services/Post/DnsUrlSafetyGuard.php (impl)
- tests/Unit/Services/Post/DnsUrlSafetyGuardTest.php
- The provider bind in AppServiceProvider

What's still defended:
- Streaming download via Http::sink with a Guzzle progress callback that
  aborts mid-stream once MAX_BYTES is exceeded (no body buffered in PHP).
- Redirects disabled so a 200→302 trick can't pivot to internal hosts.
- IP-literal SSRF guard: rejects loopback / private / link-local /
  reserved ranges via FILTER_FLAG_NO_PRIV_RANGE | NO_RES_RANGE. DNS
  hostnames are accepted — finer SSRF (DNS rebinding etc.) is left to
  network-level egress controls. This is a deliberate simplification:
  the prior DNS resolution defense added a class + interface + provider
  binding for marginal gain when the realistic attack surface is
  hard-coded internal IPs.
- Strict MIME allowlist (no SVG, no PDF, no application/*).
- Lock-then-merge into post.media[] to avoid lost-update races.

Tests bypass the SSRF check via the static `MediaAttacher::fakeUrlSafety()`
called once in tests/TestCase::setUp — same idiom as Mail::fake / Bus::fake.
2026-05-04 14:02:50 -03:00
Paulo Castellano
c871edf144 refactor: split MediaAttacher into orchestrator + downloader + safety guard
The previous MediaAttacher had ~5 responsibilities crammed in one class
(URL validation, HTTP fetch, streaming, MIME check, Storage write,
post.media merge) plus an `if ($app->runningUnitTests()) return true`
inside the SSRF guard — production code that knew about test mode.

Split into three:

- UrlSafetyGuard (interface) + DnsUrlSafetyGuard (default impl). The
  full SSRF check is now isolated and unit-testable in
  tests/Unit/Services/Post/DnsUrlSafetyGuardTest. Tests bind a permissive
  guard in tests/TestCase::setUp so feature tests using Http::fake() with
  synthetic hosts (cdn.example.com) still work — the runningUnitTests()
  check inside production code is gone.

- MediaDownloader: takes a URL, returns a temp file + MIME or null. Uses
  Http::sink with a Guzzle progress callback that throws once maxBytes
  is exceeded, so we abort mid-stream without buffering the body in PHP
  memory (the previous chunk loop was append-to-string which defeated
  the whole point of streaming).

- MediaAttacher: pure orchestrator. Calls MediaDownloader, validates the
  MIME against the post's enabled platforms, persists via Storage::putFileAs,
  appends the Media record under a row lock. Drops from 275 to ~190 lines
  with zero responsibility overlap.

The IO contract (`null` on failure, `['path','mime','bytes']` on success)
keeps MediaAttacher free of the temp-file lifecycle on the happy path —
ownership is documented in MediaDownloader's PHPDoc and the orchestrator
unlinks via try/finally in processOne().

Bind the interface in AppServiceProvider so production resolves
DnsUrlSafetyGuard automatically; tests override it.

7 new unit tests cover the SSRF cases (loopback, RFC1918, link-local,
zero/broadcast, IPv6 loopback/ULA/link-local, plus a public-IP positive
test). The 14 existing AttachMedia feature tests stay green.
2026-05-04 13:45:43 -03:00
Paulo Castellano
3b96a9ebdb refactor: move workspace tenancy check on Post into PostPolicy
The same "is this post in the user's current workspace?" check was
duplicated across every Post-related endpoint (5 in Api/PostController
via the ensurePostInCurrentWorkspace helper, 5 in App/PostController
inline). PostPolicy already had a duplicate() method following this
exact pattern — extending it with view/update/delete unifies the
tenancy guard in one place.

- Add view/update/delete to PostPolicy. Each returns
  Response::denyAsNotFound() when the post belongs to a different
  workspace, so we keep the existing 404 behavior (don't leak
  cross-tenant existence) instead of switching to the default 403.

- Update duplicate() to also use denyAsNotFound() for the workspace
  mismatch path. The createPost role check still returns bool/403.

- Replace ensurePostInCurrentWorkspace() calls in Api/PostController
  with $this->authorize('view'|'update'|'delete', $post). Helper deleted.

- Replace inline workspace_id !== $workspace->id checks in
  App/PostController (show/edit/update/destroy/platformMetrics) with
  the same authorize calls. The PostPolicy guard now subsumes both
  the workspace-tenancy check and the role-permission check that was
  previously delegated through Workspace::createPost.
2026-05-04 13:20:52 -03:00
Paulo Castellano
6949ee4ec3 refactor: derive sibling key in content-type rules without regex
Both ContentTypeMatchesPlatform and ContentTypeMatchesPostPlatform
were using preg_replace to swap the leaf segment of a dotted attribute
path ("platforms.0.content_type" -> "platforms.0.social_account_id").
Cleaner with Str::beforeLast: derive the parent path once, then look
up the sibling under it. Same intent, no regex, and the rule no longer
encodes its own attribute name.
2026-05-04 13:06:13 -03:00
Paulo Castellano
c1418c9d21 fix: address PR review findings — publish, REST store, SSRF, race
Code-review surfaced two correctness bugs and a security gap that
needed to land before merging.

- UpdatePost::execute disabled every platform when called without
  a `platforms` key. PublishPostTool relied on that path, so every
  publish-via-MCP queued a job whose handler then found nothing
  enabled to publish to. Wrap the platform toggle in
  `Arr::has($data, 'platforms')` (matches the existing label_ids
  guard a few lines up). Add a regression assertion to
  `PostPublishToolTest::publish post immediate dispatches PublishPost
  job` that the previously-enabled platform stays enabled.

- StorePostRequest declared rules for only `platforms`,
  `scheduled_at`, and `status`. `validated()` then stripped
  `content`, `media`, and `label_ids`, so REST `POST /api/posts`
  silently created empty drafts. Added rules for content / media /
  label_ids (with workspace-scoped `Rule::exists` for labels) and
  dropped the unused `status` field — REST callers transition state
  via `PUT /posts/{id}`. Removed the dead `platforms.*.content`
  rule. Added a feature test that asserts content + media + labels
  roundtrip on create, plus a regression that an `is_active=false`
  social_account is rejected at validation.

- CreatePost::execute now syncs label_ids itself so REST and MCP
  share the behavior. Removed the duplicate sync from CreatePostTool.

- MCP UpdatePostTool didn't scope `platforms.*.id` to the post being
  updated, drifting from the REST UpdatePostRequest which adds
  `Rule::exists('post_platforms','id')->where('post_id', ...)`. Now
  it loads the post first (failing fast with `Post not found.` if
  the workspace check rejects), then uses the same Rule::exists.

- MediaAttacher fetched any URL the caller passed, including
  loopback / link-local / private targets — classic SSRF pivot.
  Now `isPublicHttpUrl` rejects non-http(s) schemes, restricted IP
  ranges, and DNS hostnames whose A/AAAA records resolve into those
  ranges (covers DNS rebinding). Bypassed under
  `app()->runningUnitTests()` so `Http::fake()` keeps working.
  Streaming the response body lets us abort early once we exceed
  MAX_BYTES instead of buffering the full payload first; redirects
  are disabled so a 200→302 trick can't bypass the host check.

- The `media[]` JSON column had a lost-update race in
  `attachFromUrls`: read `$post->media`, mutate in PHP, write back.
  Two concurrent calls clobbered each other. Now wrapped in a
  transaction with `lockForUpdate()`.

- ESLint: `resources/js/actions/**` and `resources/js/routes/**`
  are auto-generated by Wayfinder on every build. Their import
  order matches PHP scan order, not alphabetical, so import/order
  fought eslint-fix forever. Added them to ignores.
2026-05-04 12:16:39 -03:00
Paulo Castellano
9a26e6d802 feat: complete create + publish post flow via MCP and REST API
Lets ChatGPT (MCP) and external clients (REST API) drive the full lifecycle of
a post — create with platform selection, attach media from URLs, schedule or
publish immediately, and fetch engagement metrics — without touching the web UI.

MCP tools added: UpdatePostTool, PublishPostTool, AttachMediaFromUrlTool,
ListContentTypesTool, GetPostMetricsTool, PreviewPostTool. CreatePostTool now
accepts platforms[] + scheduled_at + label_ids; ListPostsTool gains
status/search/limit filters.

REST endpoints added: POST /api/posts/{post}/media, GET /api/posts/{post}/metrics,
GET /api/posts/{post}/preview, GET /api/content-types.

Also fixes a silent CreatePost::execute bug — the action validated platforms[]
but ignored it, so REST callers never saw their selection persisted. Adds cross
validation rules (ContentTypeMatchesPlatform / ContentTypeMatchesPostPlatform)
so a LinkedIn account can't be saddled with x_post, and rejects inactive social
accounts during validation instead of failing silently downstream.

Shared services (PostMetricsFetcher, PostPreviewer, MediaAttacher) back both
MCP tools and REST controllers so behaviour stays aligned. New Resources
(PlatformContentTypesResource, PostMetricsResource, PostPreviewResource,
PostMediaAttachResource) keep controllers free of inline model mapping.

Suite: 1.332 passing, 0 failing — covers web (PostControllerTest), REST
(PostApiTest, PlatformApiTest, PostMediaApiTest), MCP (66 tool tests), and
the publish job (PublishToSocialPlatformTest).

Removes /docs from git tracking and TIKTOK_REVIEW_VIDEO_SCRIPT.md.
2026-05-04 08:12:28 -03:00
Paulo Castellano
6f3bdb2caa feat: add duplicate post functionality and migrate LinkedIn analytics to the /rest/ API. 2026-05-03 22:37:51 -03:00
Paulo Castellano
5658ea8c8c feat: update Bluesky to use public AppView and remove Mastodon access token from public status requests 2026-05-03 22:16:19 -03:00
Paulo Castellano
dc75e2b381 refactor: standardize post platform data structure and simplify display logic in PostResource 2026-05-03 22:11:58 -03:00
Paulo Castellano
595510812c refactor: implement lazy token refreshing and persist Mastodon scopes 2026-05-03 21:58:25 -03:00
Paulo Castellano
137cfb4f6e refactor: standardize MCP tool responses using API resources and enforce strict structural validation in feature tests 2026-05-03 21:34:20 -03:00
Paulo Castellano
ec23928908 refactor: replace Inertia-based OAuth authorization view with a static Blade template and remove unused language files. 2026-05-03 20:26:09 -03:00
Paulo Castellano
2cc0aed846 fix: enable database destructive command prohibition in production environments 2026-05-03 19:56:36 -03:00
Paulo Castellano
28ea0c8222 chore: comment out destructive command prohibition in AppServiceProvider 2026-05-03 19:45:38 -03:00
Paulo Castellano
afc47e9532 feat: transition AI usage from feature-based limits to a centralized monthly credit system with token tracking. 2026-05-03 19:36:26 -03:00
Paulo Castellano
20eeeaa493 feat: migrate custom API token implementation to Laravel Passport for authentication and token management 2026-05-03 18:38:17 -03:00
Paulo Castellano
293167d2ea refactor: remove AI image generation features and update delete user component UI 2026-05-03 17:51:16 -03:00
Paulo Castellano
34272a573f feat: implement consolidated authentication settings including session management and social provider integration 2026-05-03 17:42:44 -03:00
Paulo Castellano
0f385e1b59 refactor: reorganize billing language keys and implement AccountPolicy for subscription management 2026-05-03 17:26:55 -03:00
Paulo Castellano
488d4a41e5 feat: implement feature gating with persistent usage tracking and a reactive upgrade dialog for plan management. 2026-05-03 16:52:28 -03:00
Paulo Castellano
fe0d432f05 feat: implement settings hub dashboard and update usage metrics to support unlimited plans 2026-05-03 15:54:26 -03:00
Paulo Castellano
b47f2488d0 refactor: replace hashtags functionality with reusable signatures feature 2026-05-03 15:23:30 -03:00
Paulo Castellano
8a2f853fd5 chore: remove deprecated localization files 2026-05-03 15:23:25 -03:00
Paulo Castellano
d58af18e82 feat: implement workspace creation limits based on subscription plans and remove unused data retention feature 2026-05-03 14:38:31 -03:00
Paulo Castellano
01a220a95a refactor: remove hardcoded plan prices from models and database in favor of localized translation files 2026-05-03 14:03:31 -03:00
Paulo Castellano
d39124e90b refactor: reorganize settings UI, migrate post templates to a file-based registry, and remove legacy video generation features 2026-05-03 13:44:13 -03:00
Paulo Castellano
e6efa8410e refactor: optimize platform enum and enhance AI content generator prompts with localization updates 2026-05-03 10:17:01 -03:00
Paulo Castellano
028fe1fefd feat: add image title and body fields to AI post creation and register PostTemplateSeeder in local environments 2026-05-03 10:06:02 -03:00
Paulo Castellano
1e1519876d feat: replace legacy AI assistant with modular post content generation, review, and template management system 2026-05-03 09:36:50 -03:00
Paulo Castellano
17ca1f7b0e feat: implement paginated social accounts table with search functionality and updated UI components 2026-05-02 14:15:41 -03:00
Paulo Castellano
f4fd8a2aab refactor: centralize social API base URLs into the configuration file to improve maintainability and environment flexibility 2026-05-02 13:49:20 -03:00
Paulo Castellano
8ba5877bdf feat: implement automated brand color extraction from web metadata, CSS, and logos with supporting database schema updates 2026-05-02 13:30:59 -03:00
Paulo Castellano
f3605717c7 refactor: unify social analytics, reorganize workspace settings, and implement content validation rules 2026-05-02 12:22:42 -03:00