Merge branch 'main' into feat/posthog-onboarding-properties

This commit is contained in:
Paulo Castellano 2026-05-19 17:00:34 -03:00 committed by GitHub
commit a659a9eadf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
124 changed files with 3678 additions and 1404 deletions

78
.dockerignore Normal file
View file

@ -0,0 +1,78 @@
# Version control & editors
.git
.gitattributes
.gitignore
.github
.editorconfig
.idea
.vscode
.fleet
.nova
.zed
.phpactor.json
# Dependencies (installed inside the image)
vendor
node_modules
maizzle/node_modules
# Build artifacts (generated inside the image)
public/build
public/hot
public/storage
bootstrap/ssr
bootstrap/cache/*.php
# Generated route/action helpers (regenerated by Wayfinder at build time)
resources/js/actions
resources/js/routes
resources/js/wayfinder
# Runtime storage (mounted at runtime; shouldn't bloat build context)
storage/logs
storage/framework/cache/data
storage/framework/sessions
storage/framework/views
storage/pail
storage/*.key
storage/oauth-*.key
# Test caches
.phpunit.cache
.phpunit.result.cache
# Environment files (image must not embed secrets)
.env
.env.backup
.env.local
.env.production
.env.testing
# Composer auth
auth.json
# Personal homelab/deploy artifacts (not part of upstream)
deploy
HOMELAB-DEPLOY-GUIDE.md
# Compose overrides (per-developer)
compose.override.yaml
compose.override.yml
docker-compose.override.yaml
docker-compose.override.yml
# Docs site (if present)
docs
# OS / misc
.DS_Store
npm-debug.log
yarn-error.log
*.log
# Plan / agent context (not relevant for build)
.claude
.mcp.json
CLAUDE.md
GEMINI.md
boost.json

View file

@ -86,6 +86,13 @@ R2_REGION=auto
R2_BUCKET=
R2_URL=
# DigitalOcean Spaces (set FILESYSTEM_DISK=spaces)
SPACES_ACCESS_KEY_ID=
SPACES_SECRET_ACCESS_KEY=
SPACES_ENDPOINT=
SPACES_REGION=
SPACES_BUCKET=
# ============================================
# Social Platform Credentials
# ============================================

2
.gitignore vendored
View file

@ -2,6 +2,7 @@
/bootstrap/ssr
/node_modules
/public/build
/lang/php_*.json
/public/hot
/public/storage
/storage/*.key
@ -14,6 +15,7 @@
.env
.env.backup
.env.production
.env.testing.local
.phpactor.json
.phpunit.result.cache
Homestead.json

View file

@ -302,6 +302,13 @@ ## String Interpolation
- Use curly braces `{}` even for simple variables to keep the boundary explicit and to allow object/array access without ambiguity.
- Single quotes are still preferred when the string has no interpolation.
## External Service URLs
- NEVER hardcode third-party API hosts, OAuth endpoints, or per-platform service URLs (e.g. `https://api.x.com/2`, `https://www.linkedin.com/oauth/v2/accessToken`, `https://bsky.social`). They live in `config/trypost.php` under `platforms.<name>` with a matching `env(...)` default, so self-hosted users can override them and we have a single source of truth.
- Production code: `config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken'`, never the literal URL.
- Tests: use the same `config(...)` value in `Http::fake([...])` — `Http::fake([config('trypost.platforms.x.api').'/oauth2/token' => ...])`. Tests with hardcoded URLs drift silently when the config changes.
- Path/route segments after the host (e.g. `/oauth/v2/accessToken`, `/xrpc/com.atproto.server.refreshSession`) are part of the provider's protocol spec — those stay inline next to the call. Only the host comes from config.
## TryPost.it Documentation
- All our documentation to final user it's under https://docs.trypost.it

View file

@ -20,8 +20,10 @@ class UpdatePost
*/
public static function execute(Workspace $workspace, Post $post, array $data): array
{
if ($post->status === PostStatus::Published) {
return ['post' => $post, 'action' => PostAction::AlreadyPublished];
$terminalStatuses = [PostStatus::Published, PostStatus::PartiallyPublished, PostStatus::Failed, PostStatus::Publishing];
if (in_array($post->status, $terminalStatuses, true)) {
return ['post' => $post, 'action' => PostAction::Finalized];
}
$scheduledAt = $post->scheduled_at;

View file

@ -6,7 +6,7 @@
enum Action: string
{
case AlreadyPublished = 'already_published';
case Finalized = 'finalized';
case Publishing = 'publishing';
case Scheduled = 'scheduled';
}

View file

@ -8,6 +8,7 @@ enum Status: string
{
case Pending = 'pending';
case Publishing = 'publishing';
case Retrying = 'retrying';
case Published = 'published';
case Failed = 'failed';
}

View file

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Exceptions;
use Exception;
/**
* Raised when a social platform's API is unreachable or returning a server
* error during a token verify/refresh. Distinct from TokenExpiredException
* because the account's token is not provably invalid — the platform is
* just down. Callers should retry later instead of disconnecting the user.
*/
class PlatformUnavailableException extends Exception
{
public function __construct(
string $message = 'Platform API is unavailable',
public ?int $httpStatus = null,
) {
parent::__construct($message);
}
}

View file

@ -4,6 +4,7 @@
namespace App\Exceptions\Social;
use App\Services\Social\TokenRedactor;
use RuntimeException;
abstract class SocialPublishException extends RuntimeException
@ -27,32 +28,10 @@ public function context(): array
'category' => $this->category->value,
'platform_error_code' => $this->platformErrorCode,
'user_message' => $this->userMessage,
'raw_response' => $this->redactTokens($this->rawResponse),
'raw_response' => TokenRedactor::redact($this->rawResponse),
];
}
private function redactTokens(?string $text): ?string
{
if ($text === null) {
return null;
}
// Redact common token patterns from API error responses
return preg_replace(
[
'/access_token=([^&"\s]+)/',
'/"access_token"\s*:\s*"([^"]+)"/',
'/Bearer\s+\S+/',
],
[
'access_token=[REDACTED]',
'"access_token":"[REDACTED]"',
'Bearer [REDACTED]',
],
$text
);
}
abstract public static function fromApiResponse(mixed $response): static;
abstract public function platform(): string;

View file

@ -67,9 +67,9 @@ public function update(UpdatePostRequest $request, Post $post): PostResource|Jso
$result = UpdatePost::execute($request->user()->currentWorkspace, $post, $request->validated());
if (data_get($result, 'action') === PostAction::AlreadyPublished) {
if (data_get($result, 'action') === PostAction::Finalized) {
return response()->json(
['message' => 'Cannot edit a published post.'],
['message' => 'Cannot edit a post in a terminal state.'],
Response::HTTP_UNPROCESSABLE_ENTITY
);
}

View file

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\StoreUploadRequest;
use App\Http\Resources\Api\MediaUploadResource;
use App\Models\Media;
use App\Models\Workspace;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
class UploadController extends Controller
{
private const CACHE_TTL_BUFFER_SECONDS = 60;
public function store(StoreUploadRequest $request, string $token): JsonResponse
{
$expiresAt = (int) $request->query('expires');
$ttl = max(
self::CACHE_TTL_BUFFER_SECONDS,
$expiresAt - now()->timestamp + self::CACHE_TTL_BUFFER_SECONDS,
);
if (! Cache::add("mcp_upload:{$token}", true, $ttl)) {
abort(Response::HTTP_CONFLICT);
}
if (Media::where('upload_token', $token)->exists()) {
abort(Response::HTTP_CONFLICT);
}
$workspace = Workspace::findOrFail((string) $request->query('workspace_id'));
$media = DB::transaction(function () use ($workspace, $request, $token): Media {
$media = $workspace->addMedia($request->file('media'), 'assets');
$media->upload_token = $token;
$media->save();
return $media;
});
return MediaUploadResource::make($media)
->response()
->setStatusCode(Response::HTTP_CREATED);
}
}

View file

@ -197,7 +197,7 @@ public function show(Request $request, Post $post): Response|RedirectResponse
$this->authorize('view', $post);
if (in_array($post->status, [PostStatus::Draft, PostStatus::Scheduled, PostStatus::Failed], true)) {
if (in_array($post->status, [PostStatus::Draft, PostStatus::Scheduled], true)) {
return redirect()->route('app.posts.edit', $post);
}
@ -219,7 +219,7 @@ public function edit(Request $request, Post $post): Response|RedirectResponse
$this->authorize('update', $post);
if (in_array($post->status, [PostStatus::Publishing, PostStatus::Published, PostStatus::PartiallyPublished], true)) {
if (in_array($post->status, [PostStatus::Publishing, PostStatus::Published, PostStatus::PartiallyPublished, PostStatus::Failed], true)) {
return redirect()->route('app.posts.show', $post);
}
@ -282,8 +282,8 @@ public function update(UpdatePostRequest $request, Post $post): RedirectResponse
$action = data_get($result, 'action');
if ($action === PostAction::AlreadyPublished) {
session()->flash('flash.banner', __('posts.flash.cannot_edit_published'));
if ($action === PostAction::Finalized) {
session()->flash('flash.banner', __('posts.flash.cannot_edit_finalized'));
session()->flash('flash.bannerStyle', 'danger');
return back();

View file

@ -94,15 +94,7 @@ public function destroy(ProfileDeleteRequest $request): RedirectResponse
$user->update(['current_workspace_id' => null]);
$account = $user->account;
// Cancel account subscription if exists
if ($account && $account->subscribed(Account::SUBSCRIPTION_NAME)) {
$account->subscription(Account::SUBSCRIPTION_NAME)->cancelNow();
}
if ($account) {
$account->subscriptions()->delete();
}
$isOwner = $user->isAccountOwner();
$ownedWorkspaces = Workspace::where('user_id', $user->id)->get();
@ -126,7 +118,12 @@ public function destroy(ProfileDeleteRequest $request): RedirectResponse
$user->workspaces()->detach();
if ($account) {
if ($account && $isOwner) {
if ($account->subscribed(Account::SUBSCRIPTION_NAME)) {
$account->subscription(Account::SUBSCRIPTION_NAME)->cancelNow();
}
$account->subscriptions()->delete();
$account->delete();
}
});

View file

@ -53,7 +53,7 @@ public function store(Request $request): View|RedirectResponse
$this->authorize('manageAccounts', $workspace);
$service = 'https://bsky.social';
$service = config('trypost.platforms.bluesky.default_service');
try {
// Authenticate with Bluesky

View file

@ -57,6 +57,8 @@ public function store(Request $request): RedirectResponse
Auth::login($user);
$request->session()->forget('pending_invite_id');
if ($redirect = $request->input('redirect')) {
if (str_starts_with($redirect, '/') && ! str_starts_with($redirect, '//')) {
return redirect($redirect);

View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware\App;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class EnsureRegistrationEnabled
{
public function handle(Request $request, Closure $next): mixed
{
if (! config('trypost.self_hosted')) {
return $next($request);
}
if ($inviteId = $request->query('invite') ?? $request->session()->get('pending_invite_id')) {
$request->session()->put('pending_invite_id', $inviteId);
return $next($request);
}
throw new NotFoundHttpException;
}
}

View file

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api;
use App\Enums\Media\Type as MediaType;
use Illuminate\Foundation\Http\FormRequest;
class StoreUploadRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$allowed = implode(',', array_merge(
MediaType::Image->allowedMimeTypes(),
MediaType::Video->allowedMimeTypes(),
));
$maxKb = (int) config('ai.mcp.upload.max_size_mb') * 1024;
return [
'media' => [
'required',
'file',
"max:{$maxKb}",
"mimetypes:{$allowed}",
],
];
}
}

View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\Api;
use App\Models\Media;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* Response shape for an MCP signed-URL upload (POST /api/uploads/{token}).
*
* @mixin Media
*/
class MediaUploadResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'upload_token' => $this->upload_token,
'media_id' => $this->id,
'type' => $this->type,
'mime_type' => $this->mime_type,
'original_filename' => $this->original_filename,
];
}
}

View file

@ -10,6 +10,7 @@
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Enums\SocialAccount\Status;
use App\Events\PostPlatformStatusUpdated;
use App\Exceptions\PlatformUnavailableException;
use App\Exceptions\Social\SocialPublishException;
use App\Exceptions\TokenExpiredException;
use App\Mail\PostPublished;
@ -110,12 +111,18 @@ public function handle(): void
$result = $publisher->publish($this->postPlatform);
$this->postPlatform->markAsPublished(data_get($result, 'id'), data_get($result, 'url'));
break;
} catch (PlatformUnavailableException $e) {
$this->rescheduleForRetry($e);
break;
} catch (TokenExpiredException $e) {
if ($attempt < $maxAttempts) {
try {
$this->refreshAccountToken();
continue;
} catch (PlatformUnavailableException $refreshError) {
$this->rescheduleForRetry($refreshError);
break;
} catch (\Throwable $refreshError) {
Log::error('Token refresh failed during publish retry', [
'post_platform_id' => $this->postPlatform->id,
@ -181,6 +188,34 @@ private function refreshAccountToken(): void
app(ConnectionVerifier::class)->verify($account);
}
private function rescheduleForRetry(PlatformUnavailableException $e): void
{
$retryCount = (int) ($this->postPlatform->error_context['retry_count'] ?? 0) + 1;
$nextAttemptAt = now()->addMinutes(10);
Log::warning('Publish rescheduled: platform unavailable', [
'post_platform_id' => $this->postPlatform->id,
'platform' => $this->postPlatform->platform->value,
'retry_count' => $retryCount,
'next_attempt_at' => $nextAttemptAt->toIso8601String(),
'error' => $e->getMessage(),
]);
$this->postPlatform->update([
'status' => PostPlatformStatus::Retrying,
'error_message' => $e->getMessage(),
'error_context' => [
'category' => 'platform_unavailable',
'http_status' => $e->httpStatus,
'retry_count' => $retryCount,
'last_attempt_at' => now()->toIso8601String(),
'next_attempt_at' => $nextAttemptAt->toIso8601String(),
],
]);
self::dispatch($this->postPlatform)->delay($nextAttemptAt);
}
private function broadcastStatus(): void
{
PostPlatformStatusUpdated::dispatch($this->postPlatform->fresh());

View file

@ -4,6 +4,7 @@
namespace App\Jobs;
use App\Exceptions\PlatformUnavailableException;
use App\Exceptions\TokenExpiredException;
use App\Models\SocialAccount;
use App\Services\Social\ConnectionVerifier;
@ -24,11 +25,13 @@ public function handle(ConnectionVerifier $verifier): void
{
try {
$verifier->refreshToken($this->account);
} catch (PlatformUnavailableException $e) {
Log::warning('Token refresh skipped: platform unavailable', [
'account_id' => $this->account->id,
'platform' => $this->account->platform->value,
'error' => $e->getMessage(),
]);
} catch (TokenExpiredException $e) {
// refresh_token rejected by the provider (revoked / rotated /
// expired beyond refresh). Mark the account so the user is
// notified immediately instead of waiting for the next failed
// publish or the daily verify pass.
$this->account->markAsTokenExpired($e->getMessage());
} catch (Throwable $e) {
Log::warning('Proactive token refresh failed', [

View file

@ -7,6 +7,7 @@
use App\Enums\Notification\Channel;
use App\Enums\Notification\Type;
use App\Enums\SocialAccount\Status;
use App\Exceptions\PlatformUnavailableException;
use App\Exceptions\TokenExpiredException;
use App\Mail\WorkspaceConnectionsDisconnected;
use App\Models\SocialAccount;
@ -63,6 +64,14 @@ private function verifyAccount(ConnectionVerifier $verifier, SocialAccount $acco
try {
$verifier->verify($account);
return true;
} catch (PlatformUnavailableException $e) {
Log::warning('Social account verification skipped: platform unavailable', [
'account_id' => $account->id,
'platform' => $account->platform->value,
'error' => $e->getMessage(),
]);
return true;
} catch (TokenExpiredException $e) {
Log::warning('Social account connection is invalid', [

View file

@ -12,6 +12,7 @@
use App\Mcp\Tools\Label\ListLabelsTool;
use App\Mcp\Tools\Label\UpdateLabelTool;
use App\Mcp\Tools\Platform\ListContentTypesTool;
use App\Mcp\Tools\Post\AttachMediaFromUploadTool;
use App\Mcp\Tools\Post\AttachMediaFromUrlTool;
use App\Mcp\Tools\Post\CreatePostTool;
use App\Mcp\Tools\Post\DeletePostTool;
@ -20,6 +21,7 @@
use App\Mcp\Tools\Post\ListPostsTool;
use App\Mcp\Tools\Post\PreviewPostTool;
use App\Mcp\Tools\Post\PublishPostTool;
use App\Mcp\Tools\Post\RequestMediaUploadTool;
use App\Mcp\Tools\Post\UpdatePostTool;
use App\Mcp\Tools\Signature\CreateSignatureTool;
use App\Mcp\Tools\Signature\DeleteSignatureTool;
@ -50,6 +52,8 @@ class TryPostServer extends Server
PreviewPostTool::class,
DeletePostTool::class,
AttachMediaFromUrlTool::class,
RequestMediaUploadTool::class,
AttachMediaFromUploadTool::class,
GetPostMetricsTool::class,
// Platforms (read-only metadata)

View file

@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tools\Post;
use App\Http\Resources\Api\PostResource;
use App\Models\Media;
use App\Models\Post;
use App\Models\Workspace;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
#[Description('Attach a Media uploaded via RequestMediaUploadTool to a post. The upload_token is the value returned by RequestMediaUploadTool; the Media is resolved by that token within the current workspace, then appended to the post.')]
class AttachMediaFromUploadTool extends Tool
{
public function handle(Request $request): Response|ResponseFactory
{
$validated = $request->validate([
'post_id' => ['required', 'uuid'],
'upload_token' => ['required', 'uuid'],
]);
$workspaceId = $request->user()->current_workspace_id;
$post = Post::where('workspace_id', $workspaceId)
->find(data_get($validated, 'post_id'));
if (! $post) {
return Response::error('Post not found.');
}
$media = Media::query()
->where('upload_token', data_get($validated, 'upload_token'))
->where('mediable_type', (new Workspace)->getMorphClass())
->where('mediable_id', $workspaceId)
->first();
if (! $media) {
return Response::error('Upload not found.');
}
if (! in_array($media->type, $post->allowedMediaTypes(), true)) {
return Response::error('No enabled platform on this post accepts this media type.');
}
$post->appendMedia([[
'id' => $media->id,
'path' => $media->path,
'url' => $media->url,
'type' => $media->type,
'mime_type' => $media->mime_type,
'original_filename' => $media->original_filename,
]]);
$post->refresh()->load(['postPlatforms.socialAccount', 'labels']);
return Response::structured([
'post' => (new PostResource($post))->resolve(),
]);
}
public function schema(JsonSchema $schema): array
{
return [
'post_id' => $schema->string()->required()->description('UUID of the post to attach the uploaded media to.'),
'upload_token' => $schema->string()->required()->description('upload_token returned by RequestMediaUploadTool, after the user has POSTed the file to the upload_url.'),
];
}
}

View file

@ -47,8 +47,8 @@ public function handle(Request $request): Response|ResponseFactory
'scheduled_at' => $scheduledAt,
]);
if (data_get($result, 'action') === PostAction::AlreadyPublished) {
return Response::error('Post is already published.');
if (data_get($result, 'action') === PostAction::Finalized) {
return Response::error('Post is already published or in a terminal state.');
}
/** @var Post $updated */

View file

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tools\Post;
use Carbon\CarbonImmutable;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Str;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
#[Description('Issue a one-shot signed POST URL that lets the user upload a local file (image or video, up to the workspace upload cap — 50 MB by default) directly to this workspace. Returns an upload_token and upload_url. Hand the URL to the user (e.g. as a curl command with `-F media=@path/to/file`) or to the MCP client. After upload, call AttachMediaFromUploadTool(post_id, upload_token) to attach the result to a post.')]
class RequestMediaUploadTool extends Tool
{
public function handle(Request $request): Response|ResponseFactory
{
$user = $request->user();
$workspaceId = $user->current_workspace_id;
$maxBytes = (int) config('ai.mcp.upload.max_size_mb') * 1024 * 1024;
$ttlMinutes = (int) config('ai.mcp.upload.url_ttl_minutes');
$token = (string) Str::uuid();
$expiresAt = CarbonImmutable::now()->addMinutes($ttlMinutes);
$uploadUrl = URL::temporarySignedRoute(
'api.uploads.store',
$expiresAt,
['token' => $token, 'workspace_id' => $workspaceId],
);
return Response::structured([
'upload_token' => $token,
'upload_url' => $uploadUrl,
'expires_at' => $expiresAt->toIso8601String(),
'max_bytes' => $maxBytes,
'field_name' => 'media',
]);
}
public function schema(JsonSchema $schema): array
{
return [];
}
}

View file

@ -54,8 +54,8 @@ public function handle(Request $request): Response|ResponseFactory
$result = UpdatePost::execute($workspace, $post, $payload);
if (data_get($result, 'action') === PostAction::AlreadyPublished) {
return Response::error('Cannot edit a published post.');
if (data_get($result, 'action') === PostAction::Finalized) {
return Response::error('Cannot edit a post in a terminal state.');
}
/** @var Post $updated */

View file

@ -34,6 +34,7 @@ class Media extends Model
'size',
'order',
'meta',
'upload_token',
];
protected function casts(): array

View file

@ -102,6 +102,8 @@ public function markAsPublished(string $platformPostId, ?string $platformUrl = n
'platform_post_id' => $platformPostId,
'platform_url' => $platformUrl,
'published_at' => $now,
'error_message' => null,
'error_context' => null,
]);
$this->socialAccount?->update(['last_used_at' => $now]);
@ -113,6 +115,8 @@ public function markAsFailed(string $errorMessage, ?array $errorContext = null):
'status' => Status::Failed,
'error_message' => $errorMessage,
'error_context' => $errorContext,
'platform_post_id' => null,
'platform_url' => null,
]);
}
}

View file

@ -6,7 +6,6 @@
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\BlueskyPublishException;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Media\MediaOptimizer;
@ -26,12 +25,11 @@ public function publish(PostPlatform $postPlatform): array
$content = $postPlatform->post->content ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) : null;
$account = $postPlatform->socialAccount;
$service = $account->meta['service'] ?? 'https://bsky.social';
$service = $account->meta['service'] ?? config('trypost.platforms.bluesky.default_service');
// Refresh token if needed
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
app(ConnectionVerifier::class)->refreshToken($account);
}
$medias = $postPlatform->post->mediaItems;
@ -268,60 +266,6 @@ private function buildPostUrl(string $handle, string $postId): string
return "https://bsky.app/profile/{$handle}/post/{$postId}";
}
public function refreshToken(SocialAccount $account): void
{
$service = $account->meta['service'] ?? 'https://bsky.social';
// Try refresh first
$response = $this->socialHttp()->withToken($account->refresh_token)
->post("{$service}/xrpc/com.atproto.server.refreshSession");
if ($response->successful()) {
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'accessJwt'),
'refresh_token' => data_get($data, 'refreshJwt'),
'token_expires_at' => now()->addHours(2),
]);
return;
}
Log::warning('Bluesky refresh token failed, trying re-authentication', [
'status' => $response->status(),
]);
// If refresh fails, re-authenticate with stored credentials
if (isset($account->meta['password'])) {
try {
$password = decrypt($account->meta['password']);
$identifier = $account->meta['identifier'];
$response = Http::post("{$service}/xrpc/com.atproto.server.createSession", [
'identifier' => $identifier,
'password' => $password,
]);
if ($response->successful()) {
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'accessJwt'),
'refresh_token' => data_get($data, 'refreshJwt'),
'token_expires_at' => now()->addHours(2),
]);
return;
}
} catch (\Exception $e) {
Log::error('Bluesky re-authentication failed', [
'error' => $e->getMessage(),
]);
}
}
throw new TokenExpiredException('Bluesky session expired');
}
private function handleApiError(Response $response): never
{
throw BlueskyPublishException::fromApiResponse($response);

View file

@ -5,9 +5,8 @@
namespace App\Services\Social\Concerns;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Social\TokenRedactor;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
trait HasSocialHttpClient
@ -28,25 +27,6 @@ protected function validateContentLength(PostPlatform $postPlatform): void
);
}
protected function refreshTokenWithLock(SocialAccount $account, callable $refreshFn): void
{
$lock = Cache::lock("token_refresh:{$account->id}", 30);
if (! $lock->get()) {
// Another process is refreshing, wait and reload
sleep(2);
$account->refresh();
return;
}
try {
$refreshFn();
} finally {
$lock->release();
}
}
protected function socialHttp(): PendingRequest
{
return Http::retry(
@ -59,20 +39,6 @@ protected function socialHttp(): PendingRequest
protected function redactResponseBody(string $body): string
{
return preg_replace(
[
'/access_token=([^&"\s]+)/',
'/"access_token"\s*:\s*"([^"]+)"/',
'/Bearer\s+\S+/',
'/"token"\s*:\s*"([^"]+)"/',
],
[
'access_token=[REDACTED]',
'"access_token":"[REDACTED]"',
'Bearer [REDACTED]',
'"token":"[REDACTED]"',
],
$body
);
return TokenRedactor::redact($body);
}
}

View file

@ -5,11 +5,11 @@
namespace App\Services\Social;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\PlatformUnavailableException;
use App\Exceptions\TokenExpiredException;
use App\Models\SocialAccount;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class ConnectionVerifier
{
@ -17,6 +17,7 @@ class ConnectionVerifier
* Verify that a social account connection is still valid.
*
* @throws TokenExpiredException if the connection is invalid
* @throws PlatformUnavailableException if the platform's API is down
*/
public function verify(SocialAccount $account): bool
{
@ -72,9 +73,10 @@ private function callVerifyEndpoint(SocialAccount $account): bool
* Refresh the account's token via the platform-specific OAuth flow.
* Callers that want the smart "try access_token first" behavior should
* use verify() instead. This method always attempts a refresh under
* the per-account lock and throws TokenExpiredException on failure.
* the per-account lock.
*
* @throws TokenExpiredException if refresh fails
* @throws TokenExpiredException if refresh is rejected by the provider (4xx)
* @throws PlatformUnavailableException if the platform is unreachable (5xx / network)
*/
public function refreshToken(SocialAccount $account): void
{
@ -97,9 +99,8 @@ public function refreshToken(SocialAccount $account): void
Platform::Pinterest => $this->refreshPinterestToken($account),
Platform::Threads => $this->refreshThreadsToken($account),
Platform::Instagram => $this->refreshInstagramToken($account),
// InstagramFacebook uses page tokens that don't expire (like Facebook)
// Mastodon tokens don't expire
// Mastodon tokens don't expire
// Facebook / InstagramFacebook use Page tokens that don't expire.
// Mastodon tokens don't expire either.
default => null,
};
} finally {
@ -110,20 +111,16 @@ public function refreshToken(SocialAccount $account): void
private function refreshLinkedInToken(SocialAccount $account): void
{
if (! $account->refresh_token) {
throw new TokenExpiredException('No refresh token available for LinkedIn account');
throw new TokenExpiredException("No refresh token available for {$account->platform->label()} account");
}
$response = Http::asForm()->post('https://www.linkedin.com/oauth/v2/accessToken', [
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
'client_id' => config('services.linkedin.client_id'),
'client_secret' => config('services.linkedin.client_secret'),
]);
if ($response->failed()) {
Log::error('ConnectionVerifier: LinkedIn token refresh failed', ['body' => $this->redactBody($response->body())]);
throw new TokenExpiredException('Failed to refresh LinkedIn token');
}
$response = TokenRefreshClient::for($account->platform)->send(fn () => Http::asForm()
->post(config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken', [
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
'client_id' => config('services.linkedin.client_id'),
'client_secret' => config('services.linkedin.client_secret'),
]));
$data = $response->json();
@ -145,17 +142,12 @@ private function refreshXToken(SocialAccount $account): void
throw new TokenExpiredException('No refresh token available for X account');
}
$response = Http::asForm()
$response = TokenRefreshClient::for(Platform::X)->send(fn () => Http::asForm()
->withBasicAuth(config('services.x.client_id'), config('services.x.client_secret'))
->post(config('trypost.platforms.x.api').'/oauth2/token', [
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
]);
if ($response->failed()) {
Log::error('ConnectionVerifier: X token refresh failed', ['body' => $this->redactBody($response->body())]);
throw new TokenExpiredException('Failed to refresh X token');
}
]));
$data = $response->json();
@ -170,13 +162,13 @@ private function refreshXToken(SocialAccount $account): void
private function refreshBlueskyToken(SocialAccount $account): void
{
$service = $account->meta['service'] ?? 'https://bsky.social';
$service = $account->meta['service'] ?? config('trypost.platforms.bluesky.default_service');
$client = TokenRefreshClient::for(Platform::Bluesky);
// Try refresh token first
$response = Http::withToken($account->refresh_token)
->post("{$service}/xrpc/com.atproto.server.refreshSession");
try {
$response = $client->send(fn () => Http::withToken($account->refresh_token)
->post("{$service}/xrpc/com.atproto.server.refreshSession"));
if ($response->successful()) {
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'accessJwt'),
@ -187,35 +179,29 @@ private function refreshBlueskyToken(SocialAccount $account): void
$account->refresh();
return;
} catch (TokenExpiredException) {
// refresh token was rejected (4xx) — fall back to re-auth below
}
// If refresh fails, re-authenticate with stored credentials
if (isset($account->meta['password'])) {
try {
$password = decrypt($account->meta['password']);
$identifier = $account->meta['identifier'];
$reauth = $client->send(fn () => Http::post("{$service}/xrpc/com.atproto.server.createSession", [
'identifier' => $account->meta['identifier'],
'password' => decrypt($account->meta['password']),
]));
$response = Http::post("{$service}/xrpc/com.atproto.server.createSession", [
'identifier' => $identifier,
'password' => $password,
$data = $reauth->json();
$account->update([
'access_token' => data_get($data, 'accessJwt'),
'refresh_token' => data_get($data, 'refreshJwt'),
'token_expires_at' => now()->addHours(2),
]);
if ($response->successful()) {
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'accessJwt'),
'refresh_token' => data_get($data, 'refreshJwt'),
'token_expires_at' => now()->addHours(2),
]);
$account->refresh();
$account->refresh();
return;
}
} catch (\Exception $e) {
Log::error('ConnectionVerifier: Bluesky re-authentication failed', [
'error' => $e->getMessage(),
]);
return;
} catch (TokenExpiredException) {
// re-auth rejected with stored credentials — fall through
}
}
@ -228,17 +214,13 @@ private function refreshYouTubeToken(SocialAccount $account): void
throw new TokenExpiredException('No refresh token available for YouTube account');
}
$response = Http::asForm()->post('https://oauth2.googleapis.com/token', [
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
'client_id' => config('services.google.client_id'),
'client_secret' => config('services.google.client_secret'),
]);
if ($response->failed()) {
Log::error('ConnectionVerifier: YouTube token refresh failed', ['body' => $this->redactBody($response->body())]);
throw new TokenExpiredException('Failed to refresh YouTube token');
}
$response = TokenRefreshClient::for(Platform::YouTube)->send(fn () => Http::asForm()
->post(config('trypost.platforms.youtube.oauth_api').'/token', [
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
'client_id' => config('services.google.client_id'),
'client_secret' => config('services.google.client_secret'),
]));
$data = $response->json();
@ -256,17 +238,13 @@ private function refreshTikTokToken(SocialAccount $account): void
throw new TokenExpiredException('No refresh token available for TikTok account');
}
$response = Http::asForm()->post(config('trypost.platforms.tiktok.api').'/oauth/token/', [
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
'client_key' => config('services.tiktok.client_id'),
'client_secret' => config('services.tiktok.client_secret'),
]);
if ($response->failed()) {
Log::error('ConnectionVerifier: TikTok token refresh failed', ['body' => $this->redactBody($response->body())]);
throw new TokenExpiredException('Failed to refresh TikTok token');
}
$response = TokenRefreshClient::for(Platform::TikTok)->send(fn () => Http::asForm()
->post(config('trypost.platforms.tiktok.api').'/oauth/token/', [
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
'client_key' => config('services.tiktok.client_id'),
'client_secret' => config('services.tiktok.client_secret'),
]));
$data = $response->json();
@ -287,18 +265,13 @@ private function refreshPinterestToken(SocialAccount $account): void
$credentials = base64_encode(config('services.pinterest.client_id').':'.config('services.pinterest.client_secret'));
$response = Http::withHeaders([
$response = TokenRefreshClient::for(Platform::Pinterest)->send(fn () => Http::withHeaders([
'Authorization' => "Basic {$credentials}",
'Content-Type' => 'application/x-www-form-urlencoded',
])->asForm()->post(config('trypost.platforms.pinterest.api').'/oauth/token', [
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
]);
if ($response->failed()) {
Log::error('ConnectionVerifier: Pinterest token refresh failed', ['body' => $this->redactBody($response->body())]);
throw new TokenExpiredException('Failed to refresh Pinterest token');
}
]));
$data = $response->json();
@ -314,15 +287,10 @@ private function refreshPinterestToken(SocialAccount $account): void
private function refreshThreadsToken(SocialAccount $account): void
{
// Threads uses long-lived tokens that can be refreshed
$response = Http::get(config('trypost.platforms.threads.auth_api').'/refresh_access_token', [
$response = TokenRefreshClient::for(Platform::Threads)->send(fn () => Http::get(config('trypost.platforms.threads.auth_api').'/refresh_access_token', [
'grant_type' => 'th_refresh_token',
'access_token' => $account->access_token,
]);
if ($response->failed()) {
Log::error('ConnectionVerifier: Threads token refresh failed', ['body' => $this->redactBody($response->body())]);
throw new TokenExpiredException('Failed to refresh Threads token');
}
]));
$data = $response->json();
$newToken = data_get($data, 'access_token');
@ -338,15 +306,10 @@ private function refreshThreadsToken(SocialAccount $account): void
private function refreshInstagramToken(SocialAccount $account): void
{
$response = Http::get(config('trypost.platforms.instagram.auth_api').'/refresh_access_token', [
$response = TokenRefreshClient::for(Platform::Instagram)->send(fn () => Http::get(config('trypost.platforms.instagram.auth_api').'/refresh_access_token', [
'grant_type' => 'ig_refresh_token',
'access_token' => $account->access_token,
]);
if ($response->failed()) {
Log::error('ConnectionVerifier: Instagram token refresh failed', ['body' => $this->redactBody($response->body())]);
throw new TokenExpiredException('Failed to refresh Instagram token');
}
]));
$data = $response->json();
$newToken = data_get($data, 'access_token');
@ -524,7 +487,7 @@ private function verifyPinterest(SocialAccount $account): bool
private function verifyBluesky(SocialAccount $account): bool
{
$service = $account->meta['service'] ?? 'https://bsky.social';
$service = $account->meta['service'] ?? config('trypost.platforms.bluesky.default_service');
$response = Http::withToken($account->access_token)
->get("{$service}/xrpc/app.bsky.actor.getProfile", [
@ -543,7 +506,7 @@ private function verifyBluesky(SocialAccount $account): bool
private function verifyMastodon(SocialAccount $account): bool
{
$instance = $account->meta['instance'] ?? 'https://mastodon.social';
$instance = $account->meta['instance'] ?? config('trypost.platforms.mastodon.default_instance');
$response = Http::withToken($account->access_token)
->get("{$instance}/api/v1/accounts/verify_credentials");
@ -554,23 +517,4 @@ private function verifyMastodon(SocialAccount $account): bool
return $response->successful();
}
private function redactBody(string $body): string
{
return preg_replace(
[
'/access_token=([^&"\s]+)/',
'/"access_token"\s*:\s*"([^"]+)"/',
'/Bearer\s+\S+/',
'/"token"\s*:\s*"([^"]+)"/',
],
[
'access_token=[REDACTED]',
'"access_token":"[REDACTED]"',
'Bearer [REDACTED]',
'"token":"[REDACTED]"',
],
$body
);
}
}

View file

@ -49,7 +49,7 @@ private function publishPost(string $pageId, string $accessToken, ?string $conte
{
// Text only post
if ($media->isEmpty()) {
if (empty($content)) {
if ($content === null || $content === '') {
throw new \Exception('Facebook text posts require content. Please add text to your post.');
}
@ -102,11 +102,16 @@ private function publishTextPost(string $pageId, string $accessToken, string $co
private function publishSingleImagePost(string $pageId, string $accessToken, ?string $content, $media): array
{
$response = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/photos", [
'message' => $content,
$payload = [
'url' => $media->url,
'access_token' => $accessToken,
]);
];
if ($content !== null && $content !== '') {
$payload['message'] = $content;
}
$response = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/photos", $payload);
if ($response->failed()) {
Log::error('Facebook single image post failed', [
@ -159,10 +164,13 @@ private function publishMultiImagePost(string $pageId, string $accessToken, ?str
// Create the post with attached media
$postData = [
'message' => $content,
'access_token' => $accessToken,
];
if ($content !== null && $content !== '') {
$postData['message'] = $content;
}
foreach ($attachedMedia as $index => $media) {
$postData["attached_media[{$index}]"] = json_encode($media);
}
@ -188,12 +196,16 @@ private function publishMultiImagePost(string $pageId, string $accessToken, ?str
private function publishVideoPost(string $pageId, string $accessToken, ?string $content, $media): array
{
// Use resumable upload for videos
$response = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/videos", [
'description' => $content,
$payload = [
'file_url' => $media->url,
'access_token' => $accessToken,
]);
];
if ($content !== null && $content !== '') {
$payload['description'] = $content;
}
$response = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/videos", $payload);
if ($response->failed()) {
Log::error('Facebook video post failed', [
@ -287,13 +299,18 @@ private function publishReel(string $pageId, string $accessToken, ?string $conte
}
// Phase 3 (finish) — publish the reel.
$finishResponse = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/video_reels", [
$finishPayload = [
'upload_phase' => 'finish',
'video_id' => $videoId,
'video_state' => 'PUBLISHED',
'description' => $content,
'access_token' => $accessToken,
]);
];
if ($content !== null && $content !== '') {
$finishPayload['description'] = $content;
}
$finishResponse = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/video_reels", $finishPayload);
if ($finishResponse->failed()) {
$this->handleApiError($finishResponse);

View file

@ -5,15 +5,12 @@
namespace App\Services\Social;
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Carbon\CarbonInterface;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class InstagramAnalytics
@ -48,8 +45,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
$this->baseUrl = $account->platform->instagramGraphBaseUrl();
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
app(ConnectionVerifier::class)->refreshToken($account);
}
$this->accessToken = $account->access_token;
@ -92,8 +88,7 @@ private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $si
$this->baseUrl = $account->platform->instagramGraphBaseUrl();
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
app(ConnectionVerifier::class)->refreshToken($account);
}
$this->accessToken = $account->access_token;
@ -200,32 +195,4 @@ private function getHttpClient(): PendingRequest
{
return $this->socialHttp();
}
private function refreshToken(SocialAccount $account): void
{
if ($account->platform === Platform::InstagramFacebook) {
return;
}
if (! $account->refresh_token) {
throw new TokenExpiredException('No refresh token available for Instagram account');
}
$response = Http::get(config('trypost.platforms.instagram.auth_api').'/refresh_access_token', [
'grant_type' => 'ig_refresh_token',
'access_token' => $account->access_token,
]);
if ($response->failed()) {
Log::error('Instagram token refresh failed', ['body' => $this->redactResponseBody($response->body())]);
throw new TokenExpiredException('Instagram token refresh failed');
}
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'access_token'),
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
}
}

View file

@ -5,12 +5,9 @@
namespace App\Services\Social;
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\ErrorCategory;
use App\Exceptions\Social\InstagramPublishException;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Media\MediaOptimizer;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Illuminate\Http\Client\Response;
@ -33,8 +30,7 @@ public function publish(PostPlatform $postPlatform): array
$this->baseUrl = $account->platform->instagramGraphBaseUrl();
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
app(ConnectionVerifier::class)->refreshToken($account);
}
$instagramId = $account->platform_user_id;
@ -396,35 +392,6 @@ private function waitForMediaProcessing(string $containerId, string $accessToken
Log::warning('Instagram media processing timeout, proceeding anyway');
}
private function refreshToken(SocialAccount $account): void
{
// Instagram via Facebook uses page tokens that don't expire
if ($account->platform === Platform::InstagramFacebook) {
return;
}
$response = Http::get(config('trypost.platforms.instagram.auth_api').'/refresh_access_token', [
'grant_type' => 'ig_refresh_token',
'access_token' => $account->access_token,
]);
if ($response->failed()) {
Log::error('Instagram token refresh failed', ['body' => $this->redactResponseBody($response->body())]);
throw new TokenExpiredException('Failed to refresh Instagram token');
}
$data = $response->json();
$newToken = data_get($data, 'access_token');
$account->update([
'access_token' => $newToken,
'refresh_token' => $newToken,
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
}
private function handleApiError(Response $response): never
{
throw InstagramPublishException::fromApiResponse($response);

View file

@ -4,14 +4,12 @@
namespace App\Services\Social;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Carbon\CarbonInterface;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class LinkedInPageAnalytics
@ -53,8 +51,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
}
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
app(ConnectionVerifier::class)->refreshToken($account);
}
// platform_post_id is the share URN (e.g., "urn:li:share:12345").
@ -83,8 +80,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
{
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
app(ConnectionVerifier::class)->refreshToken($account);
}
$this->accessToken = $account->access_token;
@ -233,31 +229,4 @@ private function getHttpClient(): PendingRequest
'X-Restli-Protocol-Version' => '2.0.0',
]);
}
private function refreshToken(SocialAccount $account): void
{
if (! $account->refresh_token) {
throw new TokenExpiredException('No refresh token available for LinkedIn Page account');
}
$response = Http::asForm()->post('https://www.linkedin.com/oauth/v2/accessToken', [
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
'client_id' => config('services.linkedin-openid.client_id'),
'client_secret' => config('services.linkedin-openid.client_secret'),
]);
if ($response->failed()) {
Log::error('LinkedIn token refresh failed', ['body' => $this->redactResponseBody($response->body())]);
throw new TokenExpiredException('LinkedIn token refresh failed');
}
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'access_token'),
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
}
}

View file

@ -46,8 +46,7 @@ public function publish(PostPlatform $postPlatform): array
$this->hasRetried = false;
if ($this->account->is_token_expired || $this->account->is_token_expiring_soon) {
$this->refreshTokenWithLock($this->account, fn () => $this->refreshToken($this->account));
$this->account->refresh();
app(ConnectionVerifier::class)->refreshToken($this->account);
}
$this->accessToken = $this->account->access_token;
@ -81,8 +80,7 @@ private function retryWithRefresh(PostPlatform $postPlatform, ?string $content,
$this->hasRetried = true;
try {
$this->refreshToken($this->account);
$this->account->refresh();
app(ConnectionVerifier::class)->refreshToken($this->account);
$this->accessToken = $this->account->access_token;
$organizationId = $this->account->meta['organization_id'] ?? null;
@ -452,38 +450,6 @@ private function waitForVideoProcessing(string $videoUrn, int $maxAttempts = 30)
Log::warning('LinkedIn Page video processing timeout, proceeding anyway');
}
private function refreshToken(SocialAccount $account): void
{
if (! $account->refresh_token) {
throw new TokenExpiredException('No refresh token available for LinkedIn Page account');
}
$response = Http::asForm()->post('https://www.linkedin.com/oauth/v2/accessToken', [
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
'client_id' => config('services.linkedin-openid.client_id'),
'client_secret' => config('services.linkedin-openid.client_secret'),
]);
if ($response->failed()) {
throw new TokenExpiredException(
message: data_get($response->json(), 'error_description', 'Failed to refresh LinkedIn Page token'),
platformErrorCode: (string) $response->status(),
);
}
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'access_token'),
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
// Sync tokens to LinkedIn personal if it exists
app(LinkedInTokenSynchronizer::class)->syncTokens($account);
}
private function handleApiError(Response $response): never
{
throw LinkedInPublishException::fromApiResponse($response);

View file

@ -46,8 +46,7 @@ public function publish(PostPlatform $postPlatform): array
$this->hasRetried = false;
if ($this->account->is_token_expired || $this->account->is_token_expiring_soon) {
$this->refreshTokenWithLock($this->account, fn () => $this->refreshToken($this->account));
$this->account->refresh();
app(ConnectionVerifier::class)->refreshToken($this->account);
}
$this->accessToken = $this->account->access_token;
@ -75,8 +74,7 @@ private function retryWithRefresh(PostPlatform $postPlatform, ?string $content,
$this->hasRetried = true;
try {
$this->refreshToken($this->account);
$this->account->refresh();
app(ConnectionVerifier::class)->refreshToken($this->account);
$this->accessToken = $this->account->access_token;
$personUrn = "urn:li:person:{$this->account->platform_user_id}";
@ -433,38 +431,6 @@ private function waitForVideoProcessing(string $videoUrn, int $maxAttempts = 30)
Log::warning('LinkedIn video processing timeout, proceeding anyway');
}
private function refreshToken(SocialAccount $account): void
{
if (! $account->refresh_token) {
throw new TokenExpiredException('No refresh token available for LinkedIn account');
}
$response = Http::asForm()->post('https://www.linkedin.com/oauth/v2/accessToken', [
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
'client_id' => config('services.linkedin.client_id'),
'client_secret' => config('services.linkedin.client_secret'),
]);
if ($response->failed()) {
throw new TokenExpiredException(
message: data_get($response->json(), 'error_description', 'Failed to refresh LinkedIn token'),
platformErrorCode: (string) $response->status(),
);
}
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'access_token'),
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
// Sync tokens to LinkedIn Page if it exists
app(LinkedInTokenSynchronizer::class)->syncTokens($account);
}
private function handleApiError(Response $response): never
{
throw LinkedInPublishException::fromApiResponse($response);

View file

@ -20,7 +20,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
return ['unsupported' => true, 'reason' => 'missing_post_id'];
}
$instance = data_get($account->meta, 'instance', 'https://mastodon.social');
$instance = data_get($account->meta, 'instance', config('trypost.platforms.mastodon.default_instance'));
// Public posts: no auth needed. Our token only requests write scopes
// (read:accounts + write:statuses + write:media), so attaching the

View file

@ -25,7 +25,7 @@ public function publish(PostPlatform $postPlatform): array
$content = $postPlatform->post->content ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) : null;
$account = $postPlatform->socialAccount;
$instance = $account->meta['instance'] ?? 'https://mastodon.social';
$instance = $account->meta['instance'] ?? config('trypost.platforms.mastodon.default_instance');
$medias = $postPlatform->post->mediaItems;
$mediaIds = [];

View file

@ -4,14 +4,12 @@
namespace App\Services\Social;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Carbon\CarbonInterface;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class PinterestAnalytics
@ -49,8 +47,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
}
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
app(ConnectionVerifier::class)->refreshToken($account);
}
$start = now()->subDays(90)->format('Y-m-d');
@ -94,8 +91,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
{
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
app(ConnectionVerifier::class)->refreshToken($account);
}
$this->accessToken = $account->access_token;
@ -160,32 +156,4 @@ private function getHttpClient(): PendingRequest
{
return $this->socialHttp()->withToken($this->accessToken);
}
private function refreshToken(SocialAccount $account): void
{
if (! $account->refresh_token) {
throw new TokenExpiredException('No refresh token available for Pinterest account');
}
$response = Http::withBasicAuth(
config('services.pinterest.client_id'),
config('services.pinterest.client_secret'),
)->asForm()->post(config('trypost.platforms.pinterest.api').'/oauth/token', [
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
]);
if ($response->failed()) {
Log::error('Pinterest token refresh failed', ['body' => $this->redactResponseBody($response->body())]);
throw new TokenExpiredException('Pinterest token refresh failed');
}
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'access_token'),
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
}
}

View file

@ -8,7 +8,6 @@
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\ErrorCategory;
use App\Exceptions\Social\PinterestPublishException;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Media\MediaOptimizer;
@ -35,8 +34,7 @@ public function publish(PostPlatform $postPlatform): array
$account = $postPlatform->socialAccount;
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
app(ConnectionVerifier::class)->refreshToken($account);
}
$content = $postPlatform->post->content ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) : null;
@ -401,42 +399,13 @@ private function waitForMediaProcessing(SocialAccount $account, string $mediaId,
);
}
private function refreshToken(SocialAccount $account): void
{
$response = Http::asForm()
->withBasicAuth(
config('services.pinterest.client_id'),
config('services.pinterest.client_secret')
)
->post($this->baseUrl.'/oauth/token', [
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
]);
if ($response->failed()) {
throw new TokenExpiredException(
message: data_get($response->json(), 'error_description', 'Failed to refresh Pinterest token'),
platformErrorCode: (string) $response->status(),
);
}
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'access_token'),
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : now()->addDays(30),
]);
}
/**
* Get user's boards for board selection.
*/
public function getBoards(SocialAccount $account): array
{
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
app(ConnectionVerifier::class)->refreshToken($account);
}
$response = $this->socialHttp()->withToken($account->access_token)

View file

@ -4,14 +4,12 @@
namespace App\Services\Social;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Carbon\CarbonInterface;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class ThreadsAnalytics
@ -49,8 +47,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
}
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
app(ConnectionVerifier::class)->refreshToken($account);
}
$response = $this->socialHttp()
@ -81,8 +78,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
{
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
app(ConnectionVerifier::class)->refreshToken($account);
}
$this->accessToken = $account->access_token;
@ -138,24 +134,4 @@ private function getHttpClient(): PendingRequest
{
return $this->socialHttp();
}
private function refreshToken(SocialAccount $account): void
{
$response = Http::get(config('trypost.platforms.threads.auth_api').'/refresh_access_token', [
'grant_type' => 'th_refresh_token',
'access_token' => $account->access_token,
]);
if ($response->failed()) {
Log::error('Threads token refresh failed', ['body' => $this->redactResponseBody($response->body())]);
throw new TokenExpiredException('Threads token refresh failed');
}
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'access_token'),
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
}
}

View file

@ -5,12 +5,9 @@
namespace App\Services\Social;
use App\Exceptions\Social\ThreadsPublishException;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class ThreadsPublisher
@ -33,8 +30,7 @@ public function publish(PostPlatform $postPlatform): array
$account = $postPlatform->socialAccount;
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
app(ConnectionVerifier::class)->refreshToken($account);
}
$userId = $account->platform_user_id;
@ -303,32 +299,6 @@ private function waitForMediaProcessing(string $containerId, string $accessToken
throw new \Exception('Threads media processing timeout after '.$maxAttempts.' attempts');
}
private function refreshToken(SocialAccount $account): void
{
// Threads uses long-lived tokens that can be refreshed
$response = Http::get(config('trypost.platforms.threads.auth_api').'/refresh_access_token', [
'grant_type' => 'th_refresh_token',
'access_token' => $account->access_token,
]);
if ($response->failed()) {
throw new TokenExpiredException(
message: data_get($response->json(), 'error.message', 'Failed to refresh Threads token'),
platformErrorCode: (string) $response->status(),
);
}
$data = $response->json();
$newToken = data_get($data, 'access_token');
$account->update([
'access_token' => $newToken,
'refresh_token' => $newToken,
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
}
private function handleApiError(Response $response): never
{
throw ThreadsPublishException::fromApiResponse($response);

View file

@ -4,12 +4,10 @@
namespace App\Services\Social;
use App\Exceptions\TokenExpiredException;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class TikTokAnalytics
@ -38,8 +36,7 @@ public function getMetrics(SocialAccount $account): array
private function fetchMetricsFromApi(SocialAccount $account): array
{
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
app(ConnectionVerifier::class)->refreshToken($account);
}
$this->accessToken = $account->access_token;
@ -159,31 +156,4 @@ private function getHttpClient(): PendingRequest
{
return $this->socialHttp()->asJson()->withToken($this->accessToken);
}
private function refreshToken(SocialAccount $account): void
{
if (! $account->refresh_token) {
throw new TokenExpiredException('No refresh token available for TikTok account');
}
$response = Http::asForm()->post(config('trypost.platforms.tiktok.api').'/oauth/token/', [
'client_key' => config('services.tiktok.client_id'),
'client_secret' => config('services.tiktok.client_secret'),
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
]);
if ($response->failed()) {
Log::error('TikTok token refresh failed', ['body' => $this->redactResponseBody($response->body())]);
throw new TokenExpiredException('TikTok token refresh failed');
}
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'access_token'),
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
}
}

View file

@ -4,12 +4,10 @@
namespace App\Services\Social;
use App\Exceptions\TokenExpiredException;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class TikTokCreatorInfo
@ -61,8 +59,7 @@ public function fetch(SocialAccount $account): array
private function fetchFresh(SocialAccount $account): array
{
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
app(ConnectionVerifier::class)->refreshToken($account);
}
$this->accessToken = $account->access_token;
@ -123,31 +120,4 @@ private function getHttpClient(): PendingRequest
{
return $this->socialHttp()->asJson()->withToken($this->accessToken);
}
private function refreshToken(SocialAccount $account): void
{
if (! $account->refresh_token) {
throw new TokenExpiredException('No refresh token available for TikTok account');
}
$response = Http::asForm()->post(config('trypost.platforms.tiktok.api').'/oauth/token/', [
'client_key' => config('services.tiktok.client_id'),
'client_secret' => config('services.tiktok.client_secret'),
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
]);
if ($response->failed()) {
Log::error('TikTok token refresh failed', ['body' => $this->redactResponseBody($response->body())]);
throw new TokenExpiredException('TikTok token refresh failed');
}
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'access_token'),
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
}
}

View file

@ -4,15 +4,14 @@
namespace App\Services\Social;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\ErrorCategory;
use App\Exceptions\Social\TikTokPublishException;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class TikTokPublisher
@ -37,8 +36,7 @@ public function publish(PostPlatform $postPlatform): array
$account = $postPlatform->socialAccount;
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
app(ConnectionVerifier::class)->refreshToken($account);
}
$this->accessToken = $account->access_token;
@ -316,36 +314,6 @@ private function buildTikTokUrl(SocialAccount $account, ?string $postId = null):
return null;
}
private function refreshToken(SocialAccount $account): void
{
if (! $account->refresh_token) {
throw new TokenExpiredException('No refresh token available for TikTok account');
}
$response = Http::asForm()->post(config('trypost.platforms.tiktok.api').'/oauth/token/', [
'client_key' => config('services.tiktok.client_id'),
'client_secret' => config('services.tiktok.client_secret'),
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
]);
if ($response->failed()) {
throw new TokenExpiredException(
message: data_get($response->json(), 'error.message', 'Failed to refresh TikTok token'),
platformErrorCode: (string) $response->status(),
);
}
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'access_token'),
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
}
private function handleApiError(Response $response): never
{
throw TikTokPublishException::fromApiResponse($response);

View file

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Services\Social;
/**
* Strips OAuth tokens from raw HTTP bodies before they hit logs or
* exception messages. Centralizes the regex patterns so they evolve in
* one place — adding a new token format (e.g. provider-specific) means
* extending this list, not hunting through the codebase.
*/
class TokenRedactor
{
public static function redact(?string $body): ?string
{
if ($body === null) {
return null;
}
return preg_replace(
[
'/access_token=([^&"\s]+)/',
'/"access_token"\s*:\s*"([^"]+)"/',
'/Bearer\s+\S+/',
'/"token"\s*:\s*"([^"]+)"/',
],
[
'access_token=[REDACTED]',
'"access_token":"[REDACTED]"',
'Bearer [REDACTED]',
'"token":"[REDACTED]"',
],
$body
);
}
}

View file

@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Services\Social;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\PlatformUnavailableException;
use App\Exceptions\TokenExpiredException;
use Closure;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Log;
/**
* Normalizes the failure modes of an OAuth token-refresh request:
*
* - ConnectionException (timeout / DNS / refused) → PlatformUnavailableException
* - HTTP 5xx → PlatformUnavailableException
* - HTTP 4xx → TokenExpiredException
*
* Callers configure the actual HTTP call through the closure passed to
* `send()`, so platform-specific quirks (form vs JSON body, auth headers,
* basic auth, etc.) stay where they belong — in the per-platform refresh
* method — while the failure semantics are uniform across providers.
*/
class TokenRefreshClient
{
public function __construct(public readonly Platform $platform) {}
public static function for(Platform $platform): self
{
return new self($platform);
}
/**
* @param Closure():Response $request
*
* @throws PlatformUnavailableException
* @throws TokenExpiredException
*/
public function send(Closure $request): Response
{
$name = $this->platform->label();
try {
$response = $request();
} catch (ConnectionException $e) {
throw new PlatformUnavailableException("{$name} API unreachable: {$e->getMessage()}");
}
if ($response->serverError() || $response->status() === 429) {
throw new PlatformUnavailableException(
"{$name} API returned {$response->status()} during token refresh",
$response->status(),
);
}
if ($response->failed()) {
Log::error("TokenRefreshClient: {$name} token refresh failed", [
'body' => TokenRedactor::redact($response->body()),
]);
$body = $response->json();
$message = data_get($body, 'error_description')
?? data_get($body, 'error.message')
?? "Failed to refresh {$name} token";
throw new TokenExpiredException($message, platformErrorCode: (string) $response->status());
}
return $response;
}
}

View file

@ -4,7 +4,6 @@
namespace App\Services\Social;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
@ -48,8 +47,7 @@ public function getMetrics(SocialAccount $account, ?CarbonInterface $since = nul
private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
{
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
app(ConnectionVerifier::class)->refreshToken($account);
}
$this->accessToken = $account->access_token;
@ -164,8 +162,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
}
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
app(ConnectionVerifier::class)->refreshToken($account);
}
$this->accessToken = $account->access_token;
@ -199,32 +196,4 @@ private function getHttpClient(): PendingRequest
{
return $this->socialHttp()->withToken($this->accessToken);
}
private function refreshToken(SocialAccount $account): void
{
if (! $account->refresh_token) {
throw new TokenExpiredException('No refresh token available for X account');
}
$response = $this->socialHttp()
->withBasicAuth(config('services.x.client_id'), config('services.x.client_secret'))
->asForm()
->post(config('trypost.platforms.x.api').'/oauth2/token', [
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
]);
if ($response->failed()) {
Log::error('X token refresh failed', ['body' => $this->redactResponseBody($response->body())]);
throw new TokenExpiredException('X token refresh failed');
}
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'access_token'),
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
}
}

View file

@ -6,9 +6,7 @@
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\XPublishException;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Media\MediaOptimizer;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Illuminate\Http\Client\PendingRequest;
@ -39,8 +37,7 @@ public function publish(PostPlatform $postPlatform): array
// Refresh token if expired or expiring soon
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
app(ConnectionVerifier::class)->refreshToken($account);
}
$this->accessToken = $account->access_token;
@ -329,35 +326,6 @@ private function waitForProcessing(string $mediaId, int $maxAttempts = 20): bool
return false;
}
private function refreshToken(SocialAccount $account): void
{
if (! $account->refresh_token) {
throw new TokenExpiredException('No refresh token available for X account');
}
$response = Http::asForm()
->withBasicAuth(config('services.x.client_id'), config('services.x.client_secret'))
->post("{$this->baseUrl}/oauth2/token", [
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
]);
if ($response->failed()) {
throw new TokenExpiredException(
message: data_get($response->json(), 'error_description', 'Failed to refresh X token'),
platformErrorCode: (string) $response->status(),
);
}
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'access_token'),
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', 7200)),
]);
}
private function handleApiError(Response $response): never
{
throw XPublishException::fromApiResponse($response);

View file

@ -4,14 +4,12 @@
namespace App\Services\Social;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Carbon\CarbonInterface;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class YouTubeAnalytics
@ -49,8 +47,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
}
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
app(ConnectionVerifier::class)->refreshToken($account);
}
$this->accessToken = $account->access_token;
@ -101,8 +98,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
{
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
app(ConnectionVerifier::class)->refreshToken($account);
}
$this->accessToken = $account->access_token;
@ -161,32 +157,4 @@ private function getHttpClient(): PendingRequest
{
return $this->socialHttp()->withToken($this->accessToken);
}
private function refreshToken(SocialAccount $account): void
{
if (! $account->refresh_token) {
throw new TokenExpiredException('No refresh token available for YouTube account');
}
$response = Http::asForm()->post('https://oauth2.googleapis.com/token', [
'client_id' => config('services.google.client_id'),
'client_secret' => config('services.google.client_secret'),
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
]);
if ($response->failed()) {
Log::error('YouTube token refresh failed', ['body' => $this->redactResponseBody($response->body())]);
throw new TokenExpiredException('Failed to refresh YouTube token');
}
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'access_token'),
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
}
}

View file

@ -6,7 +6,6 @@
use App\Exceptions\Social\ErrorCategory;
use App\Exceptions\Social\YouTubePublishException;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
@ -35,8 +34,7 @@ public function publish(PostPlatform $postPlatform): array
$account = $postPlatform->socialAccount;
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
app(ConnectionVerifier::class)->refreshToken($account);
}
$media = $postPlatform->post->mediaItems;
@ -223,35 +221,6 @@ private function buildTitle(string $content): string
return $title.$shortsTag;
}
private function refreshToken(SocialAccount $account): void
{
if (! $account->refresh_token) {
throw new TokenExpiredException('No refresh token available for YouTube account');
}
$response = Http::asForm()->post('https://oauth2.googleapis.com/token', [
'client_id' => config('services.google.client_id'),
'client_secret' => config('services.google.client_secret'),
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
]);
if ($response->failed()) {
Log::error('YouTube token refresh failed', ['body' => $this->redactResponseBody($response->body())]);
throw new TokenExpiredException('Failed to refresh YouTube token');
}
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'access_token'),
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
]);
}
private function handleGoogleError(Exception $e): never
{
throw YouTubePublishException::fromGoogleException($e);

View file

@ -1,6 +1,7 @@
<?php
use App\Http\Middleware\Api\LoadWorkspaceFromToken;
use App\Http\Middleware\App\EnsureRegistrationEnabled;
use App\Http\Middleware\App\HandleInertiaRequests;
use App\Http\Middleware\App\SetLocale;
use Illuminate\Foundation\Application;
@ -32,6 +33,7 @@
$middleware->alias([
'workspace.token' => LoadWorkspaceFromToken::class,
'registration.enabled' => EnsureRegistrationEnabled::class,
]);
$middleware->preventRequestForgery(except: [

View file

@ -0,0 +1,52 @@
# TryPost — Compose override example.
#
# Compose automatically merges `compose.override.yaml` (gitignored, per-developer)
# on top of `compose.yaml`. Copy this file to start customizing:
#
# cp compose.override.yaml.example compose.override.yaml
#
# Common overrides are commented below. Uncomment what you need.
services:
app:
# Match container UID/GID to your host user (Linux contributors on
# systems where your UID isn't 1000).
# build:
# args:
# UID: 1001
# GID: 1001
# Enable Xdebug (requires installing the extension via a Dockerfile
# tweak; left commented as a hint).
# environment:
# XDEBUG_MODE: debug
# XDEBUG_CONFIG: 'client_host=host.docker.internal'
# Forward additional ports if 8000/5173/8080 conflict with another
# service on your host.
# ports:
# - '9000:80'
pgsql:
# Free up host port 5432 (e.g. when running another Postgres locally).
# ports:
# - '54320:5432'
redis:
# ports:
# - '63790:6379'
mailpit:
# Move the Mailpit UI off port 8025.
# ports:
# - '8025:8025'
# - '1025:1025'
# Add additional services here — e.g. a Selenium container for Dusk tests:
#
# selenium:
# image: selenium/standalone-chromium
# extra_hosts:
# - 'host.docker.internal:host-gateway'
# volumes:
# - '/dev/shm:/dev/shm'

View file

@ -1,91 +1,83 @@
services:
laravel.test:
app:
build:
context: './vendor/laravel/sail/runtimes/8.5'
dockerfile: Dockerfile
context: .
dockerfile: docker/Dockerfile
target: dev
args:
WWWGROUP: '${WWWGROUP}'
image: 'sail-8.5/app'
UID: ${UID:-1000}
GID: ${GID:-1000}
image: trypost-app:dev
ports:
- '${APP_PORT:-8000}:80'
- '${REVERB_PORT:-8080}:8080'
- '${VITE_PORT:-5173}:5173'
environment:
TRYPOST_DOCKER_BOOTSTRAP: '1'
UID: ${UID:-1000}
GID: ${GID:-1000}
# Real env vars take precedence over .env.testing (which pins
# DB_HOST=127.0.0.1, DB_USERNAME=root). Without these, the test
# suite can't see Postgres / Redis from inside the container.
DB_HOST: pgsql
DB_USERNAME: postgres
REDIS_HOST: redis
volumes:
- .:/var/www/html
- app-vendor:/var/www/html/vendor
- app-node-modules:/var/www/html/node_modules
extra_hosts:
- 'host.docker.internal:host-gateway'
ports:
- '${APP_PORT:-80}:80'
- '${VITE_PORT:-5173}:${VITE_PORT:-5173}'
environment:
WWWUSER: '${WWWUSER}'
LARAVEL_SAIL: 1
XDEBUG_MODE: '${SAIL_XDEBUG_MODE:-off}'
XDEBUG_CONFIG: '${SAIL_XDEBUG_CONFIG:-client_host=host.docker.internal}'
IGNITION_LOCAL_SITES_PATH: '${PWD}'
volumes:
- '.:/var/www/html'
networks:
- sail
depends_on:
- pgsql
- redis
- mailpit
- selenium
pgsql:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ['CMD', 'curl', '-fsS', 'http://127.0.0.1/up']
interval: 30s
timeout: 5s
retries: 5
start_period: 90s
pgsql:
image: 'postgres:18-alpine'
image: postgres:16-alpine
environment:
POSTGRES_DB: ${DB_DATABASE:-trypost}
POSTGRES_USER: ${DB_USERNAME:-postgres}
POSTGRES_PASSWORD: ${DB_PASSWORD:-password}
volumes:
- pgdata:/var/lib/postgresql/data
- ./docker/postgres-init.sh:/docker-entrypoint-initdb.d/10-create-test-db.sh:ro
ports:
- '${FORWARD_DB_PORT:-5432}:5432'
environment:
PGPASSWORD: '${DB_PASSWORD:-secret}'
POSTGRES_DB: '${DB_DATABASE}'
POSTGRES_USER: '${DB_USERNAME}'
POSTGRES_PASSWORD: '${DB_PASSWORD:-secret}'
volumes:
- 'sail-pgsql:/var/lib/postgresql'
- './vendor/laravel/sail/database/pgsql/create-testing-database.sql:/docker-entrypoint-initdb.d/10-create-testing-database.sql'
networks:
- sail
healthcheck:
test:
- CMD
- pg_isready
- '-q'
- '-d'
- '${DB_DATABASE}'
- '-U'
- '${DB_USERNAME}'
retries: 3
test: ['CMD-SHELL', 'pg_isready -U ${DB_USERNAME:-postgres} -d ${DB_DATABASE:-trypost}']
interval: 10s
timeout: 5s
retries: 5
redis:
image: 'redis:alpine'
image: redis:7-alpine
command: redis-server --appendonly yes
volumes:
- redisdata:/data
ports:
- '${FORWARD_REDIS_PORT:-6379}:6379'
volumes:
- 'sail-redis:/data'
networks:
- sail
healthcheck:
test:
- CMD
- redis-cli
- ping
retries: 3
timeout: 5s
test: ['CMD', 'redis-cli', 'ping']
interval: 10s
timeout: 3s
retries: 5
mailpit:
image: 'axllent/mailpit:latest'
image: axllent/mailpit:latest
ports:
- '${FORWARD_MAILPIT_PORT:-1025}:1025'
- '${FORWARD_MAILPIT_DASHBOARD_PORT:-8025}:8025'
networks:
- sail
selenium:
image: selenium/standalone-chromium
extra_hosts:
- 'host.docker.internal:host-gateway'
volumes:
- '/dev/shm:/dev/shm'
networks:
- sail
networks:
sail:
driver: bridge
- '${FORWARD_MAILPIT_SMTP_PORT:-1025}:1025'
- '${FORWARD_MAILPIT_UI_PORT:-8025}:8025'
volumes:
sail-pgsql:
driver: local
sail-redis:
driver: local
pgdata:
redisdata:
app-vendor:
app-node-modules:

View file

@ -39,6 +39,25 @@
],
],
/*
|--------------------------------------------------------------------------
| MCP Media Upload
|--------------------------------------------------------------------------
|
| Settings for the MCP signed-URL upload flow. The hard cap is enforced
| per upload regardless of media type and is intentionally smaller than
| the per-type caps in config('trypost.media') because uploads flow
| through the app server.
|
*/
'mcp' => [
'upload' => [
'max_size_mb' => (int) env('MCP_UPLOAD_MAX_SIZE_MB', 50),
'url_ttl_minutes' => (int) env('MCP_UPLOAD_URL_TTL_MINUTES', 15),
],
],
/*
|--------------------------------------------------------------------------
| AI Providers

View file

@ -15,7 +15,7 @@
|
*/
'default' => env('FILESYSTEM_DISK', 'r2'),
'default' => env('FILESYSTEM_DISK', 'public'),
/*
|--------------------------------------------------------------------------
@ -62,6 +62,16 @@
'report' => false,
],
'spaces' => [
'driver' => 's3',
'key' => env('SPACES_ACCESS_KEY_ID'),
'secret' => env('SPACES_SECRET_ACCESS_KEY'),
'endpoint' => env('SPACES_ENDPOINT'),
'region' => env('SPACES_REGION'),
'bucket' => env('SPACES_BUCKET'),
'throw' => true,
],
'r2' => [
'driver' => 's3',
'key' => env('R2_ACCESS_KEY_ID'),

View file

@ -63,6 +63,8 @@
'linkedin' => [
'enabled' => env('LINKEDIN_ENABLED', true),
'api' => env('LINKEDIN_API', 'https://api.linkedin.com'),
// OAuth host is different from the data API (api.linkedin.com).
'oauth_api' => env('LINKEDIN_OAUTH_API', 'https://www.linkedin.com'),
],
'linkedin-page' => [
'enabled' => env('LINKEDIN_PAGE_ENABLED', true),
@ -80,6 +82,7 @@
'enabled' => env('YOUTUBE_ENABLED', true),
'data_api' => env('YOUTUBE_DATA_API', 'https://www.googleapis.com/youtube/v3'),
'analytics_api' => env('YOUTUBE_ANALYTICS_API', 'https://youtubeanalytics.googleapis.com/v2'),
'oauth_api' => env('YOUTUBE_OAUTH_API', 'https://oauth2.googleapis.com'),
],
'facebook' => [
'enabled' => env('FACEBOOK_ENABLED', true),
@ -108,9 +111,13 @@
'bluesky' => [
'enabled' => env('BLUESKY_ENABLED', true),
'public_appview' => env('BLUESKY_PUBLIC_APPVIEW', 'https://public.api.bsky.app'),
// Default PDS used when the account has no `meta.service` override.
'default_service' => env('BLUESKY_DEFAULT_SERVICE', 'https://bsky.social'),
],
'mastodon' => [
'enabled' => env('MASTODON_ENABLED', true),
// Default instance used when the account has no `meta.instance` override.
'default_instance' => env('MASTODON_DEFAULT_INSTANCE', 'https://mastodon.social'),
],
],

View file

@ -18,7 +18,7 @@ public function up(): void
$table->uuid('workspace_id');
$table->uuid('user_id')->nullable();
$table->text('content')->nullable();
$table->json('media')->default('[]');
$table->json('media')->nullable()->default(null);
$table->string('status')->default('draft');
$table->timestamp('scheduled_at')->nullable();
$table->timestamp('published_at')->nullable();

View file

@ -16,7 +16,7 @@ public function up(): void
$table->uuid('user_id')->nullable();
$table->uuid('parent_id')->nullable();
$table->text('body');
$table->json('reactions')->default('[]');
$table->json('reactions')->nullable()->default(null);
$table->timestamps();
$table->foreign('post_id')->references('id')->on('posts')->cascadeOnDelete();

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('medias', function (Blueprint $table): void {
$table->uuid('upload_token')->nullable()->unique();
});
}
public function down(): void
{
Schema::table('medias', function (Blueprint $table): void {
$table->dropUnique(['upload_token']);
$table->dropColumn('upload_token');
});
}
};

View file

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Database\Seeders;
use App\Actions\User\CreateUser;
use App\Actions\Workspace\CreateWorkspace;
use App\Models\User;
use Illuminate\Database\Seeder;
class UserSeeder extends Seeder
{
public function run(): void
{
if (User::query()->exists()) {
$this->command->info('UserSeeder: a user already exists, skipping.');
return;
}
$user = CreateUser::execute([
'name' => 'Admin',
'email' => 'admin@trypost.it',
'password' => 'password',
'email_verified_at' => now(),
'timezone' => 'UTC',
]);
CreateWorkspace::execute($user, ['name' => 'My Workspace']);
$this->command->info('Admin account created — change the password on first login:');
$this->command->line(' email: admin@trypost.it');
$this->command->line(' password: password');
}
}

163
docker/.env.docker.example Normal file
View file

@ -0,0 +1,163 @@
APP_NAME="TryPost"
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost:8000
# Self-hosted mode (skips payment requirements)
SELF_HOSTED=true
TELESCOPE_ENABLED=false
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
# Database — service name "pgsql" inside the Compose network.
DB_CONNECTION=pgsql
DB_HOST=pgsql
DB_PORT=5432
DB_DATABASE=trypost
DB_USERNAME=postgres
DB_PASSWORD=password
# Session
SESSION_DRIVER=database
SESSION_LIFETIME=1440
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
# Broadcasting, Queue, Cache — all Redis-backed in Docker.
BROADCAST_CONNECTION=reverb
QUEUE_CONNECTION=redis
CACHE_STORE=redis
# File Storage — local-disk by default; switch to s3/r2 for production.
FILESYSTEM_DISK=local
# Redis — service name "redis" inside the Compose network.
REDIS_HOST=redis
REDIS_PASSWORD=null
REDIS_PORT=6379
# Mail — service name "mailpit" inside the Compose network. UI at :8025.
MAIL_MAILER=smtp
MAIL_HOST=mailpit
MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
# Reverb (WebSockets) — host=localhost is browser-perspective via host port-forward.
REVERB_APP_ID=1001
REVERB_APP_KEY=trypost-reverb-key
REVERB_APP_SECRET=trypost-reverb-secret
REVERB_HOST=localhost
REVERB_PORT=8080
REVERB_SCHEME=http
# AWS S3 (set FILESYSTEM_DISK=s3)
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_URL=
# Cloudflare R2 (set FILESYSTEM_DISK=r2)
R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=
R2_ENDPOINT=
R2_REGION=auto
R2_BUCKET=
R2_URL=
# ============================================
# Social Platform Credentials (optional in dev)
# ============================================
LINKEDIN_CLIENT_ID=
LINKEDIN_CLIENT_SECRET=
LINKEDIN_CLIENT_REDIRECT="${APP_URL}/accounts/linkedin/callback"
LINKEDIN_PAGE_CLIENT_REDIRECT="${APP_URL}/accounts/linkedin-page/callback"
X_CLIENT_ID=
X_CLIENT_SECRET=
X_CLIENT_REDIRECT="${APP_URL}/accounts/x/callback"
TIKTOK_CLIENT_ID=
TIKTOK_CLIENT_SECRET=
TIKTOK_CLIENT_REDIRECT="${APP_URL}/accounts/tiktok/callback"
FACEBOOK_CLIENT_ID=
FACEBOOK_CLIENT_SECRET=
FACEBOOK_CLIENT_REDIRECT="${APP_URL}/accounts/facebook/callback"
INSTAGRAM_CLIENT_ID=
INSTAGRAM_CLIENT_SECRET=
INSTAGRAM_CLIENT_REDIRECT="${APP_URL}/accounts/instagram/callback"
THREADS_CLIENT_ID=
THREADS_CLIENT_SECRET=
THREADS_CLIENT_REDIRECT="${APP_URL}/accounts/threads/callback"
GOOGLE_AUTH_ENABLED=false
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_CLIENT_REDIRECT="${APP_URL}/accounts/youtube/callback"
GOOGLE_AUTH_CALLBACK="${APP_URL}/auth/google/callback"
GITHUB_AUTH_ENABLED=false
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GITHUB_AUTH_CALLBACK="${APP_URL}/auth/github/callback"
PINTEREST_CLIENT_ID=
PINTEREST_CLIENT_SECRET=
PINTEREST_CLIENT_REDIRECT="${APP_URL}/accounts/pinterest/callback"
# AI Services
OPENAI_API_KEY=
ANTHROPIC_API_KEY=
GEMINI_API_KEY=
ELEVENLABS_API_KEY=
AI_TEXT_PROVIDER=openai
AI_TEXT_MODEL=gpt-5.4
AI_IMAGE_PROVIDER=openai
AI_AUDIO_PROVIDER=elevenlabs
# Telemetry — disabled by default in Docker dev.
NIGHTWATCH_ENABLED=false
NIGHTWATCH_TOKEN=
# Media Services
UNSPLASH_ACCESS_KEY=
UNSPLASH_SECRET_KEY=
GIPHY_API_KEY=
# Analytics (optional)
GTM_ID=
POSTHOG_ENABLED=false
POSTHOG_API_KEY=
POSTHOG_HOST=https://us.i.posthog.com
# Vite
VITE_APP_NAME="${APP_NAME}"
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
VITE_REVERB_HOST="${REVERB_HOST}"
VITE_REVERB_PORT="${REVERB_PORT}"
VITE_REVERB_SCHEME="${REVERB_SCHEME}"
VITE_POSTHOG_ENABLED="${POSTHOG_ENABLED}"
VITE_POSTHOG_API_KEY="${POSTHOG_API_KEY}"
VITE_POSTHOG_HOST="${POSTHOG_HOST}"

212
docker/Dockerfile Normal file
View file

@ -0,0 +1,212 @@
# syntax=docker/dockerfile:1.7
# ----------------------------------------------------------------------------
# TryPost Dockerfile — multi-stage with `dev` and `production` targets.
#
# docker build --target dev -t trypost:dev -f docker/Dockerfile .
# docker build --target production -t trypost:prod -f docker/Dockerfile .
# ----------------------------------------------------------------------------
ARG PHP_VERSION=8.4
ARG NODE_VERSION=22
# ----------------------------------------------------------------------------
# Stage 1: system-base — PHP-FPM + system packages + extensions
# ----------------------------------------------------------------------------
FROM php:${PHP_VERSION}-fpm-alpine AS system-base
RUN apk add --no-cache \
nginx \
supervisor \
bash \
curl \
git \
unzip \
shadow \
tzdata \
postgresql-client \
postgresql-dev \
libpng-dev \
libjpeg-turbo-dev \
freetype-dev \
libwebp-dev \
libavif-dev \
libzip-dev \
oniguruma-dev \
icu-dev \
linux-headers \
$PHPIZE_DEPS \
&& docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp --with-avif \
&& docker-php-ext-install -j"$(nproc)" \
pdo_pgsql \
pgsql \
gd \
zip \
opcache \
bcmath \
exif \
pcntl \
intl \
sockets \
&& pecl install redis \
&& docker-php-ext-enable redis \
&& apk del $PHPIZE_DEPS \
&& rm -rf /tmp/* /var/cache/apk/*
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /var/www/html
# ----------------------------------------------------------------------------
# Stage 2: composer-deps — full PHP deps (incl. dev) for tooling/asset build
# ----------------------------------------------------------------------------
FROM system-base AS composer-deps
COPY composer.json composer.lock ./
RUN composer install \
--no-scripts \
--no-autoloader \
--prefer-dist \
--no-interaction
# ----------------------------------------------------------------------------
# Stage 3: composer-deps-prod — production deps only (no dev tooling)
# ----------------------------------------------------------------------------
FROM system-base AS composer-deps-prod
COPY composer.json composer.lock ./
RUN composer install \
--no-dev \
--no-scripts \
--no-autoloader \
--prefer-dist \
--no-interaction
# ----------------------------------------------------------------------------
# Stage 4: asset-build — wayfinder TS gen + Vite + Inertia SSR build
#
# PHP and Node together in one stage because @laravel/vite-plugin-wayfinder
# invokes `php artisan wayfinder:generate` during vite build — splitting
# the stages would mean the vite phase has no PHP available.
#
# Vite inlines VITE_* values into the JS bundle at build time. Pass them
# as build args (--build-arg VITE_REVERB_APP_KEY=...) so the bundle reaches
# the browser with the correct Reverb/PostHog config. Runtime env vars on
# the container have no effect on the already-compiled bundle.
# ----------------------------------------------------------------------------
FROM composer-deps AS asset-build
# Node 22 for Vite + npm scripts.
RUN apk add --no-cache nodejs npm
ARG VITE_APP_NAME=TryPost
ARG VITE_REVERB_APP_KEY=
ARG VITE_REVERB_HOST=localhost
ARG VITE_REVERB_PORT=8080
ARG VITE_REVERB_SCHEME=http
ARG VITE_POSTHOG_ENABLED=false
ARG VITE_POSTHOG_API_KEY=
ARG VITE_POSTHOG_HOST=https://us.i.posthog.com
# Stub PHP envs so artisan can boot (overridden at runtime).
ENV APP_KEY=base64:c3R1Yi13YXlmaW5kZXItZ2VuLWtleS1mb3ItYnVpbGRpbmctYXNzZXRzMA== \
APP_ENV=production \
APP_DEBUG=false \
APP_URL=http://localhost \
VITE_APP_NAME=${VITE_APP_NAME} \
VITE_REVERB_APP_KEY=${VITE_REVERB_APP_KEY} \
VITE_REVERB_HOST=${VITE_REVERB_HOST} \
VITE_REVERB_PORT=${VITE_REVERB_PORT} \
VITE_REVERB_SCHEME=${VITE_REVERB_SCHEME} \
VITE_POSTHOG_ENABLED=${VITE_POSTHOG_ENABLED} \
VITE_POSTHOG_API_KEY=${VITE_POSTHOG_API_KEY} \
VITE_POSTHOG_HOST=${VITE_POSTHOG_HOST}
COPY --from=composer-deps /var/www/html/vendor ./vendor
COPY . .
# Ensure Laravel runtime dirs exist — .dockerignore strips their contents
# (correct: caches shouldn't bloat builds), but artisan boot needs the dirs.
RUN mkdir -p storage/framework/cache/data \
storage/framework/sessions \
storage/framework/views \
storage/logs \
bootstrap/cache
RUN composer dump-autoload --no-scripts --optimize \
&& php artisan wayfinder:generate --with-form \
&& npm ci --no-audit --no-fund \
&& npm run build \
&& npm run build:ssr
# ----------------------------------------------------------------------------
# Stage 6: dev — local development image (bind-mount the source at runtime)
# ----------------------------------------------------------------------------
FROM system-base AS dev
ARG UID=1000
ARG GID=1000
# Node + npm for in-container Vite, npm scripts, ad-hoc tooling.
RUN apk add --no-cache nodejs npm
# Create non-root app user matching host UID/GID for clean bind-mount writes.
RUN groupmod -g "${GID}" www-data 2>/dev/null || groupadd -g "${GID}" app \
&& (id -u app >/dev/null 2>&1 || useradd -u "${UID}" -g "${GID}" -d /home/app -m -s /bin/bash app) \
&& chown -R "${UID}:${GID}" /var/www/html
COPY docker/nginx.conf /etc/nginx/http.d/default.conf
COPY docker/php.dev.ini /usr/local/etc/php/conf.d/99-trypost.ini
COPY docker/supervisord.dev.conf /etc/supervisor/conf.d/supervisord.conf
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
COPY docker/.env.docker.example /var/www/html/.env.docker.example
RUN chmod +x /usr/local/bin/entrypoint.sh
ENV TRYPOST_TARGET=dev
EXPOSE 80 5173 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=5 \
CMD curl -fsS http://127.0.0.1/up || exit 1
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
# ----------------------------------------------------------------------------
# Stage 7: production — self-contained image for self-hosters
# ----------------------------------------------------------------------------
FROM system-base AS production
# php-fpm runs as www-data (Alpine default pool config) — that's the user
# storage and bootstrap/cache must be owned by, so Laravel can write logs,
# session files, view cache, etc.
COPY docker/nginx.conf /etc/nginx/http.d/default.conf
COPY docker/php.prod.ini /usr/local/etc/php/conf.d/99-trypost.ini
COPY docker/supervisord.prod.conf /etc/supervisor/conf.d/supervisord.conf
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
# Application source + generated wayfinder TS + built assets — all from
# asset-build (the merged stage that ran php artisan wayfinder:generate,
# npm run build, and npm run build:ssr).
COPY --from=asset-build /var/www/html /var/www/html
# Replace the dev-equipped vendor/ with production-only deps. composer-deps-prod
# is built independently and is much smaller (no phpunit, no debugbar, no pail,
# no pint). Also wipe bootstrap/cache/*.php — package:discover ran with dev
# deps and recorded providers (Pail, Telescope etc) that no longer exist;
# Laravel rediscovers cleanly at first boot.
RUN rm -rf /var/www/html/vendor /var/www/html/bootstrap/cache/*.php
COPY --from=composer-deps-prod /var/www/html/vendor /var/www/html/vendor
RUN composer dump-autoload --optimize --classmap-authoritative --no-scripts \
&& chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache
ENV TRYPOST_TARGET=production
EXPOSE 80 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD curl -fsS http://127.0.0.1/up || exit 1
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]

112
docker/entrypoint.sh Executable file
View file

@ -0,0 +1,112 @@
#!/bin/sh
# TryPost — container entrypoint. Idempotent first-run setup, then exec supervisord.
set -e
cd /var/www/html
TARGET="${TRYPOST_TARGET:-dev}"
# 1) Bootstrap .env from the Docker template on first dev boot. The bind-mount
# in dev hides /var/www/html/.env.docker.example, so prefer docker/ first.
if [ "${TRYPOST_DOCKER_BOOTSTRAP:-0}" = "1" ] && [ ! -f .env ]; then
if [ -f docker/.env.docker.example ]; then
echo "[entrypoint] seeding .env from docker/.env.docker.example"
cp docker/.env.docker.example .env
elif [ -f .env.docker.example ]; then
echo "[entrypoint] seeding .env from .env.docker.example"
cp .env.docker.example .env
fi
# Hand the seeded .env over to the host user so they can edit it.
chown "${UID:-1000}:${GID:-1000}" .env 2>/dev/null || true
fi
# 2) Skip-bootstrap escape hatch for advanced users.
if [ "${TRYPOST_SKIP_BOOTSTRAP:-0}" = "1" ]; then
echo "[entrypoint] TRYPOST_SKIP_BOOTSTRAP=1 — exec'ing supervisord without setup"
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf
fi
# 3) Re-install composer deps if vendor was wiped (down -v in dev).
if [ "${TARGET}" = "dev" ] && [ ! -f vendor/autoload.php ]; then
echo "[entrypoint] vendor/ missing — running composer install"
composer install --no-interaction --prefer-dist
fi
# 4) Re-install node_modules if empty/wiped (dev only; vite needs them).
# Anonymous volumes pre-create the dir, so check for npm's lockfile instead.
if [ "${TARGET}" = "dev" ] && [ ! -f node_modules/.package-lock.json ]; then
echo "[entrypoint] node_modules/ empty — running npm ci"
npm ci --no-audit --no-fund
fi
# 5) APP_KEY — generate on first boot if blank.
if [ -f .env ] && ! grep -qE '^APP_KEY=base64:' .env; then
echo "[entrypoint] generating APP_KEY"
php artisan key:generate --force
fi
# 6) Wait for Postgres to be reachable.
DB_HOST_VALUE="${DB_HOST:-pgsql}"
DB_PORT_VALUE="${DB_PORT:-5432}"
DB_USER_VALUE="${DB_USERNAME:-postgres}"
DB_NAME_VALUE="${DB_DATABASE:-trypost}"
echo "[entrypoint] waiting for postgres at ${DB_HOST_VALUE}:${DB_PORT_VALUE}"
WAIT_ATTEMPTS=0
until pg_isready -h "${DB_HOST_VALUE}" -p "${DB_PORT_VALUE}" -U "${DB_USER_VALUE}" -d "${DB_NAME_VALUE}" >/dev/null 2>&1; do
WAIT_ATTEMPTS=$((WAIT_ATTEMPTS + 1))
if [ "${WAIT_ATTEMPTS}" -gt 60 ]; then
echo "[entrypoint] postgres not reachable after 60s — continuing anyway"
break
fi
sleep 1
done
# 7) Run migrations (graceful: succeeds even when nothing to migrate).
echo "[entrypoint] running migrations"
php artisan migrate --force --graceful || true
# 8) storage:link if missing.
if [ ! -L public/storage ]; then
echo "[entrypoint] linking storage"
php artisan storage:link --force || true
fi
# 9) Passport keys on first boot.
if [ ! -f storage/oauth-private.key ]; then
echo "[entrypoint] generating Passport keys"
php artisan passport:keys --force || true
fi
# 10) Wayfinder TS regen — Vite needs the files before it boots.
echo "[entrypoint] regenerating wayfinder helpers"
php artisan wayfinder:generate --with-form || true
# 11) Cache strategy: prod = pre-cache; dev = clear.
if [ "${TARGET}" = "production" ]; then
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache
else
php artisan config:clear
php artisan route:clear
php artisan view:clear
php artisan event:clear
fi
# 12) Permissions. Production php-fpm pool runs as www-data (Alpine default),
# so storage and bootstrap/cache must be writable by that user — Laravel
# needs to write session files, view cache, log files, etc.
if [ "${TARGET}" = "production" ]; then
chown -R www-data:www-data storage bootstrap/cache
else
# Dev: ensure UID-mapped user owns runtime dirs.
APP_UID="${UID:-1000}"
APP_GID="${GID:-1000}"
chown -R "${APP_UID}:${APP_GID}" storage bootstrap/cache 2>/dev/null || true
fi
echo "[entrypoint] ready — handing off to supervisord"
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf

56
docker/nginx.conf Normal file
View file

@ -0,0 +1,56 @@
server {
listen 80;
listen [::]:80;
server_name _;
root /var/www/html/public;
index index.php;
charset utf-8;
client_max_body_size 1G;
# Reverb WebSocket (Pusher protocol). The browser connects to
# wss://<host>/app/<key>; the broadcaster REST API lives under /apps/.
# Both are proxied to the in-container Reverb on 127.0.0.1:8080.
location /app/ {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
location /apps/ {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
error_page 404 /index.php;
location ~ \.php$ {
fastcgi_pass 127.0.0.1:9000;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_read_timeout 120s;
include fastcgi_params;
fastcgi_hide_header X-Powered-By;
}
location ~ /\.(?!well-known).* {
deny all;
}
}

22
docker/php.dev.ini Normal file
View file

@ -0,0 +1,22 @@
; TryPost — PHP settings for local development.
memory_limit = 512M
upload_max_filesize = 1G
post_max_size = 1G
max_execution_time = 120
max_input_vars = 5000
; Hot-reload PHP edits without restarting FPM.
opcache.enable = 1
opcache.memory_consumption = 128
opcache.max_accelerated_files = 10000
opcache.validate_timestamps = 1
opcache.revalidate_freq = 0
; Surface errors loudly in dev.
display_errors = On
display_startup_errors = On
error_reporting = E_ALL
log_errors = On
error_log = /proc/self/fd/2
; Xdebug optional — enable via XDEBUG_MODE in compose.override.yaml.

20
docker/php.prod.ini Normal file
View file

@ -0,0 +1,20 @@
; TryPost — PHP settings for production.
memory_limit = 256M
upload_max_filesize = 1G
post_max_size = 1G
max_execution_time = 60
max_input_vars = 5000
; OpCache hardened for production: no timestamp checks, larger interned-string buffer.
opcache.enable = 1
opcache.memory_consumption = 192
opcache.max_accelerated_files = 20000
opcache.validate_timestamps = 0
opcache.interned_strings_buffer = 16
; Security
expose_php = Off
display_errors = Off
display_startup_errors = Off
log_errors = On
error_log = /proc/self/fd/2

11
docker/postgres-init.sh Executable file
View file

@ -0,0 +1,11 @@
#!/bin/sh
# Creates the test database expected by phpunit.xml on first Postgres boot.
# Postgres-alpine runs every *.sh / *.sql in /docker-entrypoint-initdb.d/ once,
# right after the primary database (POSTGRES_DB) is initialized.
set -e
psql -v ON_ERROR_STOP=1 --username "${POSTGRES_USER}" --dbname "${POSTGRES_DB}" <<-EOSQL
CREATE DATABASE trypost_test;
GRANT ALL PRIVILEGES ON DATABASE trypost_test TO "${POSTGRES_USER}";
EOSQL

View file

@ -0,0 +1,69 @@
[supervisord]
nodaemon=true
user=root
logfile=/dev/null
logfile_maxbytes=0
pidfile=/run/supervisord.pid
[program:php-fpm]
command=php-fpm -F
autostart=true
autorestart=true
priority=10
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:nginx]
command=nginx -g 'daemon off;'
autostart=true
autorestart=true
priority=20
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:reverb]
command=php /var/www/html/artisan reverb:start --host=0.0.0.0 --port=8080
autostart=true
autorestart=true
priority=30
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:horizon]
command=php /var/www/html/artisan horizon
autostart=true
autorestart=true
priority=30
stopwaitsecs=10
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:scheduler]
command=php /var/www/html/artisan schedule:work
autostart=true
autorestart=true
priority=40
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:vite]
command=/bin/sh -c "exec /usr/bin/npm run dev -- --host 0.0.0.0"
directory=/var/www/html
autostart=true
autorestart=true
startsecs=10
priority=50
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0

View file

@ -0,0 +1,58 @@
[supervisord]
nodaemon=true
user=root
logfile=/dev/null
logfile_maxbytes=0
pidfile=/run/supervisord.pid
[program:php-fpm]
command=php-fpm -F
autostart=true
autorestart=true
priority=10
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:nginx]
command=nginx -g 'daemon off;'
autostart=true
autorestart=true
priority=20
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:reverb]
command=php /var/www/html/artisan reverb:start --host=0.0.0.0 --port=8080
autostart=true
autorestart=unexpected
exitcodes=0,2
priority=30
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:horizon]
command=php /var/www/html/artisan horizon
autostart=true
autorestart=true
priority=30
stopwaitsecs=10
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:scheduler]
command=php /var/www/html/artisan schedule:work
autostart=true
autorestart=true
priority=40
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0

View file

@ -175,6 +175,7 @@
'draft' => 'Draft',
'scheduled' => 'Scheduled',
'publishing' => 'Publishing',
'retrying' => 'Retrying',
'published' => 'Published',
'partially_published' => 'Partially Published',
'failed' => 'Failed',
@ -239,6 +240,7 @@
'no_labels' => 'No labels created yet',
'schedule' => 'Schedule',
'pick_time' => 'Pick time',
'pick_time_past' => 'Pick a future date and time.',
'post_now' => 'Post now',
'time' => 'Time',
'cancel' => 'Cancel',
@ -269,6 +271,7 @@
'platform_status' => 'Platform status',
'compliance_incomplete' => 'Some platform settings are incomplete or incompatible with the attached media.',
'compliance' => [
'requires_content_or_media' => 'Add text or media to publish.',
'requires_media' => 'Add an image or video to publish here.',
'too_many_files' => 'Only :max file(s) allowed for this format.',
'too_few_files' => 'Add at least :min files for this format.',
@ -323,6 +326,7 @@
'scheduled' => 'Scheduled',
'published' => 'Published',
'publishing' => 'Publishing...',
'retrying' => 'Retrying...',
'failed' => 'Failed',
],
@ -473,7 +477,7 @@
'scheduled' => 'Post scheduled successfully!',
'deleted' => 'Post deleted successfully!',
'duplicated' => 'Post duplicated as a draft.',
'cannot_edit_published' => 'Published posts cannot be edited.',
'cannot_edit_finalized' => 'This post has already been processed and cannot be re-published. Duplicate it to try again.',
'cannot_delete_published' => 'Published posts cannot be deleted.',
'connect_first' => 'Connect at least one social network before creating a post.',
],

View file

@ -175,6 +175,7 @@
'draft' => 'Borrador',
'scheduled' => 'Programado',
'publishing' => 'Publicando',
'retrying' => 'Reintentando',
'published' => 'Publicado',
'partially_published' => 'Parcialmente publicado',
'failed' => 'Fallido',
@ -261,6 +262,7 @@
'organize' => 'Organizar',
'no_labels' => 'Todavía no hay etiquetas creadas',
'pick_time' => 'Elegir hora',
'pick_time_past' => 'Elige una fecha y hora en el futuro.',
'post_now' => 'Publicar ahora',
'time' => 'Hora',
'cancel' => 'Cancelar',
@ -269,6 +271,7 @@
'platform_status' => 'Estado de la plataforma',
'compliance_incomplete' => 'Algunas configuraciones de plataforma están incompletas o son incompatibles con los medios adjuntos.',
'compliance' => [
'requires_content_or_media' => 'Agrega texto o multimedia para publicar.',
'requires_media' => 'Agrega una imagen o video para publicar aquí.',
'too_many_files' => 'Solo se permiten :max archivo(s) en este formato.',
'too_few_files' => 'Agrega al menos :min archivos para este formato.',
@ -323,6 +326,7 @@
'scheduled' => 'Programado',
'published' => 'Publicado',
'publishing' => 'Publicando...',
'retrying' => 'Reintentando...',
'failed' => 'Fallido',
],
@ -473,7 +477,7 @@
'scheduled' => '¡Post programado correctamente!',
'deleted' => '¡Post eliminado correctamente!',
'duplicated' => 'Post duplicado como borrador.',
'cannot_edit_published' => 'Los posts publicados no se pueden editar.',
'cannot_edit_finalized' => 'Este post ya fue procesado y no puede republicarse. Duplícalo para intentar de nuevo.',
'cannot_delete_published' => 'Los posts publicados no se pueden eliminar.',
'connect_first' => 'Conecta al menos una red social antes de crear un post.',
],

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -175,6 +175,7 @@
'draft' => 'Rascunho',
'scheduled' => 'Agendado',
'publishing' => 'Publicando',
'retrying' => 'Tentando novamente',
'published' => 'Publicado',
'partially_published' => 'Parcialmente Publicado',
'failed' => 'Falhou',
@ -261,6 +262,7 @@
'organize' => 'Organizar',
'no_labels' => 'Nenhuma etiqueta criada ainda',
'pick_time' => 'Escolher horário',
'pick_time_past' => 'Escolha uma data e hora no futuro.',
'post_now' => 'Publicar agora',
'time' => 'Horário',
'cancel' => 'Cancelar',
@ -269,6 +271,7 @@
'platform_status' => 'Status da plataforma',
'compliance_incomplete' => 'Algumas configurações de plataforma estão incompletas ou incompatíveis com a mídia anexada.',
'compliance' => [
'requires_content_or_media' => 'Adicione texto ou mídia para publicar.',
'requires_media' => 'Adicione uma imagem ou vídeo para publicar aqui.',
'too_many_files' => 'Apenas :max arquivo(s) permitido(s) para este formato.',
'too_few_files' => 'Adicione pelo menos :min arquivos para este formato.',
@ -323,6 +326,7 @@
'scheduled' => 'Agendado',
'published' => 'Publicado',
'publishing' => 'Publicando...',
'retrying' => 'Tentando novamente...',
'failed' => 'Falhou',
],
@ -473,7 +477,7 @@
'scheduled' => 'Post agendado com sucesso!',
'deleted' => 'Post excluído com sucesso!',
'duplicated' => 'Post duplicado como rascunho.',
'cannot_edit_published' => 'Posts publicados não podem ser editados.',
'cannot_edit_finalized' => 'Este post já foi processado e não pode ser republicado. Duplique-o para tentar de novo.',
'cannot_delete_published' => 'Posts publicados não podem ser excluídos.',
'connect_first' => 'Conecte pelo menos uma rede social antes de criar um post.',
],

View file

@ -14,10 +14,11 @@ import { Skeleton } from '@/components/ui/skeleton';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import debounce from '@/debounce';
import { destroy as assetsDestroy, search as assetsSearch, store as assetsStore, storeFromUrl } from '@/routes/app/assets';
import { destroy as assetsDestroy, search as assetsSearch, storeChunked as assetsStoreChunked, storeFromUrl } from '@/routes/app/assets';
import { search as giphySearch, trending as giphyTrending } from '@/routes/app/assets/giphy';
import { search as unsplashSearch, trending as unsplashTrending } from '@/routes/app/assets/unsplash';
import { store as storePost } from '@/routes/app/posts';
import { uploadChunked } from '@/utils/chunkedUpload';
interface AssetMedia {
id: string;
@ -152,7 +153,6 @@ const fileInput = ref<HTMLInputElement | null>(null);
const uploadsSentinel = useTemplateRef<HTMLDivElement>('uploadsSentinel');
const isDragging = ref(false);
const uploading = ref(false);
const httpUpload = useHttp<{ media: File | null }>({ media: null });
let uploadsObserver: IntersectionObserver | null = null;
const fetchUploads = async (page: number, term: string) => {
@ -236,8 +236,11 @@ const uploadFiles = async (files: File[]) => {
uploading.value = true;
for (const file of files) {
try {
httpUpload.media = file;
await httpUpload.post(assetsStore.url());
await uploadChunked({
file,
url: assetsStoreChunked.url(),
collection: 'assets',
});
} catch {
// ignore individual failure
}

View file

@ -65,11 +65,14 @@ const buildDateTime = (): string => {
return `${dateStr}T${selectedHour.value}:${selectedMinute.value}:00`;
};
const isPastDateTime = computed(() => dayjs(buildDateTime()).isBefore(dayjs()));
const cancel = () => {
open.value = false;
};
const confirm = () => {
if (isPastDateTime.value) return;
const value = buildDateTime();
emit('update:modelValue', value);
emit('confirm', value);
@ -112,6 +115,9 @@ const remove = () => {
</Select>
<span v-if="timezoneAbbr" class="ml-1 text-xs text-muted-foreground">{{ timezoneAbbr }}</span>
</div>
<p v-if="isPastDateTime" class="mt-2 text-xs font-semibold text-rose-700">
{{ $t('posts.edit.pick_time_past') }}
</p>
</div>
<div class="flex items-center justify-between gap-2 border-t p-3">
@ -126,7 +132,7 @@ const remove = () => {
{{ $t('posts.edit.unschedule') }}
</Button>
<Button v-else type="button" variant="ghost" size="sm" @click="cancel">{{ $t('posts.edit.cancel') }}</Button>
<Button type="button" size="sm" @click="confirm">{{ $t('posts.edit.pick_time') }}</Button>
<Button type="button" size="sm" :disabled="isPastDateTime" @click="confirm">{{ $t('posts.edit.pick_time') }}</Button>
</div>
</DialogContent>
</Dialog>

View file

@ -1,5 +1,4 @@
<script setup lang="ts">
import { usePage } from '@inertiajs/vue3';
import { IconAlertTriangle, IconChevronDown, IconChevronUp } from '@tabler/icons-vue';
import { computed, ref } from 'vue';
@ -16,6 +15,7 @@ import {
ComboboxTrigger,
} from '@/components/ui/combobox';
import { getMediaValidationWarning, type MediaItem } from '@/composables/useMedia';
import { usePageErrors } from '@/composables/usePageErrors';
import { getPlatformLogo } from '@/composables/usePlatformLogo';
import { ContentType } from '@/enums/content-type';
import type { PinterestBoard } from '@/types';
@ -79,11 +79,10 @@ const selectedBoard = computed<BoardOption | undefined>({
// (`platforms.0.meta.board_id`). Suffix match avoids threading the index
// through props. Cleared as soon as a board is picked locally so the user
// doesn't see a stale error after fixing the issue.
const page = usePage();
const errors = usePageErrors();
const boardError = computed<string | undefined>(() => {
if (props.meta?.board_id) return undefined;
const errors = (page.props.errors as Record<string, string> | undefined) ?? {};
return Object.entries(errors).find(([key]) => key.endsWith('.meta.board_id'))?.[1];
return Object.entries(errors.value).find(([key]) => key.endsWith('.meta.board_id'))?.[1];
});
</script>

View file

@ -2,9 +2,12 @@
import { IconCalendar, IconCircleCheck, IconLoader2, IconTrash } from '@tabler/icons-vue';
import { computed } from 'vue';
import InputError from '@/components/InputError.vue';
import PickTimePopover from '@/components/posts/PickTimePopover.vue';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { usePageErrors } from '@/composables/usePageErrors';
import { PostStatus } from '@/types/post';
interface Props {
post: { status: string };
@ -27,9 +30,15 @@ const emit = defineEmits<{
(e: 'submit', status: string): void;
}>();
const isReadOnly = computed(() => ['publishing', 'published', 'partially_published'].includes(props.post.status));
const isScheduled = computed(() => props.post.status === 'scheduled');
const isPublished = computed(() => ['published', 'partially_published'].includes(props.post.status));
const READONLY_STATUSES: readonly string[] = [PostStatus.Publishing, PostStatus.Published, PostStatus.PartiallyPublished, PostStatus.Failed];
const PUBLISHED_STATUSES: readonly string[] = [PostStatus.Published, PostStatus.PartiallyPublished];
const isReadOnly = computed(() => READONLY_STATUSES.includes(props.post.status));
const isScheduled = computed(() => props.post.status === PostStatus.Scheduled);
const isPublished = computed(() => PUBLISHED_STATUSES.includes(props.post.status));
const errors = usePageErrors();
const scheduledAtError = computed(() => errors.value.scheduled_at);
</script>
<template>
@ -82,49 +91,68 @@ const isPublished = computed(() => ['published', 'partially_published'].includes
</span>
</div>
<div v-if="!isReadOnly" class="flex items-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button
type="button"
variant="outline"
size="icon"
class="bg-rose-100 hover:bg-rose-200"
:disabled="isSaving || isSubmitting"
@click="emit('delete')"
>
<IconTrash class="size-4 text-rose-700" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ $t('posts.edit.delete') }}</TooltipContent>
</Tooltip>
</TooltipProvider>
<div v-if="!isReadOnly" class="flex flex-col items-end gap-1">
<div class="flex items-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button
type="button"
variant="outline"
size="icon"
class="bg-rose-100 hover:bg-rose-200"
:disabled="isSaving || isSubmitting"
@click="emit('delete')"
>
<IconTrash class="size-4 text-rose-700" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ $t('posts.edit.delete') }}</TooltipContent>
</Tooltip>
<PickTimePopover
v-model="scheduledDateTime"
:disabled="isPostActionDisabled"
@confirm="hasPickedTime = true"
>
<Button
type="button"
variant="outline"
:disabled="isPostActionDisabled"
:title="postActionTooltip"
>
<IconCalendar class="size-4" />
{{ pickTimeLabel }}
</Button>
</PickTimePopover>
<Tooltip>
<TooltipTrigger as-child>
<span tabindex="0">
<PickTimePopover
v-model="scheduledDateTime"
:disabled="isPostActionDisabled"
@confirm="hasPickedTime = true"
>
<Button
type="button"
variant="outline"
:disabled="isPostActionDisabled"
>
<IconCalendar class="size-4" />
{{ pickTimeLabel }}
</Button>
</PickTimePopover>
</span>
</TooltipTrigger>
<TooltipContent v-if="postActionTooltip" class="max-w-xs whitespace-pre-line">
{{ postActionTooltip }}
</TooltipContent>
</Tooltip>
<Button
type="button"
:disabled="isPostActionDisabled"
:title="postActionTooltip"
@click="emit('submit', hasPickedTime ? 'scheduled' : 'publishing')"
>
{{ hasPickedTime ? $t('posts.edit.schedule') : $t('posts.edit.post_now') }}
</Button>
<Tooltip>
<TooltipTrigger as-child>
<span tabindex="0">
<Button
type="button"
:disabled="isPostActionDisabled"
@click="emit('submit', hasPickedTime ? PostStatus.Scheduled : PostStatus.Publishing)"
>
{{ hasPickedTime ? $t('posts.edit.schedule') : $t('posts.edit.post_now') }}
</Button>
</span>
</TooltipTrigger>
<TooltipContent v-if="postActionTooltip" class="max-w-xs whitespace-pre-line">
{{ postActionTooltip }}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<InputError :message="scheduledAtError" />
</div>
</template>
</header>

View file

@ -11,9 +11,11 @@ import TikTokSettings from '@/components/posts/editor/TikTokSettings.vue';
import { Avatar } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { usePageErrors } from '@/composables/usePageErrors';
import { getPlatformLabel, getPlatformLogo } from '@/composables/usePlatformLogo';
import { Platform } from '@/enums/platform';
import type { PinterestBoard } from '@/types';
import { PostPlatformStatus } from '@/types/post';
interface SocialAccount {
id: string;
@ -151,6 +153,24 @@ const getPlatformDisplayName = (pp: PostPlatform): string =>
const getPlatformAvatar = (pp: PostPlatform): string | null =>
pp.social_account?.avatar_url ?? pp.platform_avatar ?? null;
// Map pp.id → submit-array index, matching what Edit.vue sends as `platforms[i]`.
// Backend validation errors are keyed `platforms.{i}.content_type`, so we use this
// to surface the right error under each platform's variant picker.
const errors = usePageErrors();
const submitIndexByPpId = computed<Record<string, number>>(() => {
const map: Record<string, number> = {};
props.postPlatforms
.filter((pp) => props.selectedPlatformIds.includes(pp.id))
.forEach((pp, index) => { map[pp.id] = index; });
return map;
});
const contentTypeErrorFor = (pp: PostPlatform): string | undefined => {
const index = submitIndexByPpId.value[pp.id];
if (index === undefined) return undefined;
return errors.value[`platforms.${index}.content_type`];
};
</script>
<template>
@ -199,10 +219,10 @@ const getPlatformAvatar = (pp: PostPlatform): string | null =>
>
<IconAlertCircle class="h-2.5 w-2.5" />
</Badge>
<Badge v-else-if="pp.status === 'published'" variant="success" class="absolute -top-1 -right-1 h-4 w-4 p-0">
<Badge v-else-if="pp.status === PostPlatformStatus.Published" variant="success" class="absolute -top-1 -right-1 h-4 w-4 p-0">
<IconCircleCheck class="h-2.5 w-2.5" />
</Badge>
<Badge v-else-if="pp.status === 'failed'" variant="destructive" class="absolute -top-1 -right-1 h-4 w-4 p-0 text-[9px]">!</Badge>
<Badge v-else-if="pp.status === PostPlatformStatus.Failed" variant="destructive" class="absolute -top-1 -right-1 h-4 w-4 p-0 text-[9px]">!</Badge>
</div>
<span
class="line-clamp-2 text-center text-xs leading-tight"
@ -226,7 +246,7 @@ const getPlatformAvatar = (pp: PostPlatform): string | null =>
</div>
</div>
<div v-if="postPlatforms.some(pp => pp.status !== 'pending')">
<div v-if="postPlatforms.some(pp => pp.status !== PostPlatformStatus.Pending)">
<p class="mb-2 text-[11px] font-black uppercase tracking-widest text-foreground/60">
{{ $t('posts.edit.platform_status') }}
</p>
@ -243,12 +263,12 @@ const getPlatformAvatar = (pp: PostPlatform): string | null =>
<span class="text-sm font-bold text-foreground">{{ getPlatformDisplayName(pp) }}</span>
</div>
<div class="flex items-center gap-2">
<Badge v-if="pp.status === 'published'" variant="success">{{ $t('posts.edit.status.published') }}</Badge>
<Badge v-else-if="pp.status === 'publishing'" variant="warning">
<Badge v-if="pp.status === PostPlatformStatus.Published" variant="success">{{ $t('posts.edit.status.published') }}</Badge>
<Badge v-else-if="pp.status === PostPlatformStatus.Publishing" variant="warning">
<IconLoader2 class="size-3 animate-spin" />
{{ $t('posts.edit.status.publishing') }}
</Badge>
<Badge v-else-if="pp.status === 'failed'" variant="destructive">{{ $t('posts.edit.status.failed') }}</Badge>
<Badge v-else-if="pp.status === PostPlatformStatus.Failed" variant="destructive">{{ $t('posts.edit.status.failed') }}</Badge>
<a
v-if="pp.platform_url"
:href="pp.platform_url"
@ -273,6 +293,7 @@ const getPlatformAvatar = (pp: PostPlatform): string | null =>
:creator-info="getCreatorInfo(pp)"
:video-duration-sec="videoDurationSec"
:content-type="platformContentTypes[pp.id] ?? ''"
:content-type-error="contentTypeErrorFor(pp)"
:meta="platformMeta[pp.id] ?? {}"
:disabled="isReadOnly"
@update:content-type="emit('update:platformContentType', pp.id, $event)"

View file

@ -4,6 +4,7 @@ import { trans } from 'laravel-vue-i18n';
import { computed, ref, watch } from 'vue';
import { toast } from 'vue-sonner';
import InputError from '@/components/InputError.vue';
import { Avatar } from '@/components/ui/avatar';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
@ -42,6 +43,7 @@ interface Props {
creatorInfo?: CreatorInfo | null;
videoDurationSec?: number | null;
contentType: string;
contentTypeError?: string;
meta: Record<string, any>;
disabled?: boolean;
}
@ -49,6 +51,7 @@ interface Props {
const props = withDefaults(defineProps<Props>(), {
creatorInfo: null,
videoDurationSec: null,
contentTypeError: undefined,
disabled: false,
});
@ -232,6 +235,7 @@ watch(
{{ $t(variant.labelKey) }}
</button>
</div>
<InputError :message="contentTypeError" />
</div>
<!-- Creator identity -->

View file

@ -1,193 +0,0 @@
import { ref, type Ref } from 'vue';
import { getMediaRulesForContentType } from '@/composables/useMediaRules';
import { store as storeAsset, storeChunked as storeAssetChunked } from '@/routes/app/assets';
import { uploadChunked, shouldUseChunkedUpload } from '@/utils/chunkedUpload';
export interface MediaItem {
id: string;
path: string;
url: string;
type?: string;
mime_type?: string;
original_filename?: string;
}
interface PostPlatform {
id: string;
platform: string;
content_type: string | null;
media: MediaItem[];
}
interface UseMediaManagerOptions {
synced: Ref<boolean>;
selectedPlatformIds: Ref<string[]>;
platformContentTypes: Ref<Record<string, string>>;
postPlatforms: Ref<PostPlatform[]>;
}
export const useMediaManager = (options: UseMediaManagerOptions) => {
const { synced, selectedPlatformIds, platformContentTypes, postPlatforms } = options;
const platformMedia = ref<Record<string, MediaItem[]>>(
Object.fromEntries(postPlatforms.value.map((pp) => [pp.id, pp.media || []])),
);
const isUploading = ref<Record<string, boolean>>({});
const isSingleMediaContentType = (platformId: string): boolean => {
const platform = postPlatforms.value.find((pp) => pp.id === platformId);
if (!platform) return false;
const contentType = platformContentTypes.value[platformId] || platform.content_type;
if (!contentType) return false;
const rules = getMediaRulesForContentType(contentType);
return rules.maxFiles === 1;
};
const csrfToken = document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content ?? '';
const uploadToAssets = async (file: File): Promise<MediaItem | null> => {
try {
if (shouldUseChunkedUpload(file)) {
const data = await uploadChunked({
file,
url: storeAssetChunked.url(),
model: 'workspace',
modelId: '',
collection: 'assets',
});
return {
id: data.id,
path: data.path ?? '',
url: data.url,
type: data.type,
mime_type: data.mime_type,
original_filename: data.original_filename,
};
}
const formData = new FormData();
formData.append('media', file);
const response = await fetch(storeAsset.url(), {
method: 'POST',
headers: {
Accept: 'application/json',
'X-CSRF-TOKEN': csrfToken,
'X-Requested-With': 'XMLHttpRequest',
},
body: formData,
});
if (!response.ok) return null;
const data = await response.json();
return {
id: data.id,
path: data.path ?? '',
url: data.url,
type: data.type,
mime_type: data.mime_type,
original_filename: data.original_filename,
};
} catch {
return null;
}
};
const upload = async (files: File[], postPlatformId: string) => {
if (!files || files.length === 0) return;
const platform = postPlatforms.value.find((pp) => pp.id === postPlatformId);
const contentType = platformContentTypes.value[postPlatformId] || platform?.content_type || '';
const rules = getMediaRulesForContentType(contentType);
const targetPlatformIds = synced.value ? selectedPlatformIds.value : [postPlatformId];
for (const id of targetPlatformIds) {
isUploading.value[id] = true;
}
if (rules.maxFiles === 1) {
platformMedia.value[postPlatformId] = [];
}
const currentMedia = platformMedia.value[postPlatformId] || [];
const remainingSlots = rules.maxFiles - currentMedia.length;
const filesToUpload = Array.from(files).slice(0, remainingSlots);
if (filesToUpload.length === 0) {
for (const id of targetPlatformIds) {
isUploading.value[id] = false;
}
return;
}
for (const file of filesToUpload) {
const mediaItem = await uploadToAssets(file);
if (!mediaItem) continue;
for (const targetId of targetPlatformIds) {
if (isSingleMediaContentType(targetId)) {
platformMedia.value[targetId] = [mediaItem];
} else {
platformMedia.value[targetId] = [...(platformMedia.value[targetId] || []), mediaItem];
}
}
}
for (const id of targetPlatformIds) {
isUploading.value[id] = false;
}
};
const remove = (postPlatformId: string, mediaId: string) => {
const targetPlatformIds = synced.value ? selectedPlatformIds.value : [postPlatformId];
for (const targetId of targetPlatformIds) {
platformMedia.value[targetId] = (platformMedia.value[targetId] || []).filter((m) => m.id !== mediaId);
}
};
const reorder = (postPlatformId: string, mediaIds: string[]) => {
const currentMedia = platformMedia.value[postPlatformId] || [];
const reorderedMedia = mediaIds
.map((id) => currentMedia.find((m) => m.id === id))
.filter(Boolean) as MediaItem[];
const targetPlatformIds = synced.value ? selectedPlatformIds.value : [postPlatformId];
for (const targetId of targetPlatformIds) {
if (isSingleMediaContentType(targetId)) {
platformMedia.value[targetId] = reorderedMedia.length > 0 ? [reorderedMedia[0]] : [];
} else {
const targetMedia = platformMedia.value[targetId] || [];
const reorderedIds = reorderedMedia.map((m) => m.id);
platformMedia.value[targetId] = reorderedIds
.map((id) => targetMedia.find((m) => m.id === id) || reorderedMedia.find((m) => m.id === id))
.filter(Boolean) as MediaItem[];
}
}
};
const getMedia = (platformId: string): MediaItem[] => {
return platformMedia.value[platformId] || [];
};
const isUploadingFor = (platformId: string): boolean => {
return isUploading.value[platformId] || false;
};
return {
platformMedia,
isUploading,
upload,
remove,
reorder,
getMedia,
isUploadingFor,
isSingleMediaContentType,
};
};

View file

@ -87,6 +87,10 @@ const CONTENT_TYPE_RULES: Record<string, MediaRules> = {
acceptsGif: false,
// maxVideoDurationSec is enforced dynamically via creator_info
},
tiktok_photo: {
maxFiles: 35, minFiles: 1, acceptImages: true, acceptVideos: false, requiresMedia: true,
acceptsGif: false,
},
// YouTube
youtube_short: {

View file

@ -0,0 +1,14 @@
import { usePage } from '@inertiajs/vue3';
import { computed, type ComputedRef } from 'vue';
/**
* Reactive access to Inertia's page-level validation errors, keyed by field
* path (e.g. `scheduled_at`, `platforms.0.content_type`). Returns an empty
* object when no errors are present so consumers can index without null
* checks. Cast to `Record<string, string>` because the app only ever flashes
* single string messages (no nested error bags).
*/
export const usePageErrors = (): ComputedRef<Record<string, string>> => {
const page = usePage();
return computed(() => (page.props.errors ?? {}) as Record<string, string>);
};

View file

@ -0,0 +1,236 @@
import { trans } from 'laravel-vue-i18n';
import { computed, type ComputedRef, type Ref } from 'vue';
import { getMediaItemIssue, type MediaItem } from '@/composables/useMedia';
import { getMediaRulesForContentType } from '@/composables/useMediaRules';
import { getPlatformLabel } from '@/composables/usePlatformLogo';
import { ContentType } from '@/enums/content-type';
import { Platform } from '@/enums/platform';
export interface CompliancePostPlatform {
id: string;
platform: string;
platform_name: string | null;
social_account_id: string | null;
content_type: string | null;
}
export interface CompliancePost {
post_platforms: CompliancePostPlatform[];
}
export const PLATFORM_VARIANTS: Record<string, string[]> = {
[Platform.Facebook]: [ContentType.FacebookPost, ContentType.FacebookReel, ContentType.FacebookStory],
[Platform.Instagram]: [ContentType.InstagramFeed, ContentType.InstagramReel, ContentType.InstagramStory],
[Platform.InstagramFacebook]: [ContentType.InstagramFeed, ContentType.InstagramReel, ContentType.InstagramStory],
[Platform.LinkedIn]: [ContentType.LinkedInPost, ContentType.LinkedInCarousel],
[Platform.LinkedInPage]: [ContentType.LinkedInPagePost, ContentType.LinkedInPageCarousel],
[Platform.TikTok]: [ContentType.TikTokVideo, ContentType.TikTokPhoto],
[Platform.Pinterest]: [ContentType.PinterestPin, ContentType.PinterestVideoPin, ContentType.PinterestCarousel],
};
type MetaRule = (meta: Record<string, any>) => { valid: boolean; tooltipKey: string | null };
// Platforms whose `meta` blob has publish-time requirements. `valid` gates
// scheduling; `tooltipKey` (when set) surfaces a platform-specific message
// — null means "blocks the publish but no dedicated message, fall through
// to the generic incomplete tooltip".
const PLATFORM_META_RULES: Record<string, MetaRule> = {
[Platform.TikTok]: (meta) => {
const disclosureIncomplete = Boolean(meta.disclose)
&& !meta.brand_organic_toggle
&& !meta.brand_content_toggle;
const privacyLevelMissing = !meta.privacy_level;
return {
valid: !disclosureIncomplete && !privacyLevelMissing,
tooltipKey: disclosureIncomplete ? 'posts.form.tiktok.compliance_incomplete' : null,
};
},
[Platform.Pinterest]: (meta) => ({
valid: Boolean(meta.board_id),
tooltipKey: meta.board_id ? null : 'posts.form.pinterest.board_required',
}),
};
export const getMediaIncompatibilityReason = (
contentType: string,
mediaItems: MediaItem[],
): string | null => {
const rules = getMediaRulesForContentType(contentType);
const videos = mediaItems.filter((m) => m.type === 'video' || m.mime_type?.startsWith('video/'));
const images = mediaItems.filter((m) => m.type === 'image' || m.mime_type?.startsWith('image/'));
const gifs = mediaItems.filter((m) => m.mime_type === 'image/gif');
const total = mediaItems.length;
if (rules.requiresMedia && total === 0) return trans('posts.edit.compliance.requires_media');
if (!rules.acceptVideos && videos.length > 0) return trans('posts.edit.compliance.no_videos');
if (!rules.acceptImages && images.length > 0) return trans('posts.edit.compliance.no_images');
if (!rules.acceptsGif && gifs.length > 0) return trans('posts.edit.compliance.no_gifs');
if (total > rules.maxFiles) return trans('posts.edit.compliance.too_many_files', { max: String(rules.maxFiles) });
if (rules.minFiles && total < rules.minFiles) return trans('posts.edit.compliance.too_few_files', { min: String(rules.minFiles) });
for (const m of mediaItems) {
const isVideo = m.type === 'video' || m.mime_type?.startsWith('video/');
const size = m.size ?? 0;
const duration = m.meta?.duration ?? 0;
const width = m.meta?.width ?? 0;
const height = m.meta?.height ?? 0;
if (isVideo) {
if (rules.maxVideoBytes && size > 0 && size > rules.maxVideoBytes) return trans('posts.edit.compliance.video_too_large');
if (rules.maxVideoDurationSec && duration > 0 && duration > rules.maxVideoDurationSec) {
return trans('posts.edit.compliance.video_too_long', { seconds: String(rules.maxVideoDurationSec) });
}
} else if (rules.maxImageBytes && size > 0 && size > rules.maxImageBytes) {
return trans('posts.edit.compliance.image_too_large');
}
if (width > 0 && height > 0 && (rules.aspectRatioMin || rules.aspectRatioMax)) {
const ratio = width / height;
if (rules.aspectRatioMin && ratio < rules.aspectRatioMin) return trans('posts.edit.compliance.aspect_ratio_invalid');
if (rules.aspectRatioMax && ratio > rules.aspectRatioMax) return trans('posts.edit.compliance.aspect_ratio_invalid');
}
}
return null;
};
export const firstCompatibleVariant = (
platform: string,
mediaItems: MediaItem[],
): string | null => {
const variants = PLATFORM_VARIANTS[platform];
if (!variants) return null;
return variants.find((ct) => !getMediaIncompatibilityReason(ct, mediaItems)) ?? null;
};
interface UsePostComplianceOptions {
post: ComputedRef<CompliancePost>;
content: Ref<string>;
media: Ref<MediaItem[]>;
selectedPlatformIds: Ref<string[]>;
platformContentTypes: Ref<Record<string, string>>;
platformMeta: Ref<Record<string, Record<string, any>>>;
platformConfigs: Record<string, { maxContentLength?: number | null }>;
}
export const usePostCompliance = (opts: UsePostComplianceOptions) => {
const { post, content, media, selectedPlatformIds, platformContentTypes, platformMeta, platformConfigs } = opts;
const selectedPlatforms = computed(() => post.value.post_platforms.filter(
(pp) => selectedPlatformIds.value.includes(pp.id),
));
const platformLimits = computed(() => {
const seen = new Set<string>();
const result: { platform: string; maxLength: number }[] = [];
for (const pp of selectedPlatforms.value) {
if (seen.has(pp.platform)) continue;
const max = pp.social_account_id ? platformConfigs[pp.social_account_id]?.maxContentLength : null;
if (typeof max === 'number' && max > 0) {
seen.add(pp.platform);
result.push({ platform: pp.platform, maxLength: max });
}
}
return result;
});
const mediaIssues = computed<Record<string, { platform: string; reason: string }[]>>(() => {
const result: Record<string, { platform: string; reason: string }[]> = {};
for (const item of media.value) {
const issues: { platform: string; reason: string }[] = [];
const seen = new Set<string>();
for (const pp of selectedPlatforms.value) {
if (seen.has(pp.platform)) continue;
const contentType = platformContentTypes.value[pp.id] ?? pp.content_type ?? '';
const reason = getMediaItemIssue(item, contentType);
if (reason) {
seen.add(pp.platform);
issues.push({ platform: pp.platform, reason });
}
}
if (issues.length > 0) result[item.id] = issues;
}
return result;
});
const platformIssues = computed<Record<string, string>>(() => {
const issues: Record<string, string> = {};
for (const pp of post.value.post_platforms) {
const contentType = platformContentTypes.value[pp.id];
if (!contentType) {
issues[pp.id] = trans('posts.edit.compliance.no_content_type');
continue;
}
const reason = getMediaIncompatibilityReason(contentType, media.value);
if (!reason) continue;
const isSelected = selectedPlatformIds.value.includes(pp.id);
if (!isSelected && firstCompatibleVariant(pp.platform, media.value)) continue;
issues[pp.id] = reason;
}
return issues;
});
const platformMetaResults = computed(() => selectedPlatforms.value.map((pp) => {
const rule = PLATFORM_META_RULES[pp.platform];
if (!rule) return { valid: true, tooltipKey: null };
return rule(platformMeta.value[pp.id] ?? {});
}));
const hasContentOrMedia = computed(
() => content.value.trim().length > 0 || media.value.length > 0,
);
const contentLengthOverflows = computed(() => {
const len = content.value.length;
return platformLimits.value
.filter((p) => len > p.maxLength)
.map((p) => ({ platform: p.platform, limit: p.maxLength, over: len - p.maxLength }));
});
const canSchedule = computed(() => {
const mediaValid = selectedPlatformIds.value.every((id) => !platformIssues.value[id]);
const metaValid = platformMetaResults.value.every((r) => r.valid);
return mediaValid
&& metaValid
&& hasContentOrMedia.value
&& contentLengthOverflows.value.length === 0;
});
const postActionTooltip = computed(() => {
if (canSchedule.value) return '';
const mediaReasons = selectedPlatforms.value
.filter((pp) => platformIssues.value[pp.id])
.map((pp) => `${pp.platform_name ?? pp.platform}: ${platformIssues.value[pp.id]}`);
const lengthReasons = contentLengthOverflows.value.map((overflow) => trans('posts.form.content_exceeds_platform', {
platform: getPlatformLabel(overflow.platform),
limit: String(overflow.limit),
over: String(overflow.over),
}));
const combined = [...mediaReasons, ...lengthReasons].join('\n');
if (combined) return combined;
const metaTooltipKey = platformMetaResults.value.find((r) => r.tooltipKey)?.tooltipKey;
if (metaTooltipKey) return trans(metaTooltipKey);
if (!hasContentOrMedia.value) return trans('posts.edit.compliance.requires_content_or_media');
return trans('posts.edit.compliance_incomplete');
});
return {
platformLimits,
mediaIssues,
platformIssues,
canSchedule,
postActionTooltip,
};
};

View file

@ -19,6 +19,7 @@ const CONFIGS: Record<string, Pick<StatusConfig, 'variant' | 'icon'>> = {
draft: { variant: 'outline', icon: IconFileText },
scheduled: { variant: 'default', icon: IconClock },
publishing: { variant: 'warning', icon: IconLoader2 },
retrying: { variant: 'warning', icon: IconLoader2 },
published: { variant: 'success', icon: IconCircleCheck },
partially_published: { variant: 'warning', icon: IconAlertCircle },
failed: { variant: 'destructive', icon: IconAlertCircle },
@ -33,6 +34,7 @@ export const getPlatformStatusConfig = (status: string): StatusConfig => {
const map: Record<string, string> = {
pending: 'draft',
publishing: 'publishing',
retrying: 'retrying',
published: 'published',
failed: 'failed',
};

View file

@ -165,6 +165,29 @@ export default {
return dayjs(date).format('YYYY-MM-DD');
},
/**
* Converte uma data UTC para o formato esperado por inputs HTML `datetime-local`
* (YYYY-MM-DDTHH:mm:00) no timezone do usuário.
* @param date - Data em UTC (ISO string) ou nulo
* @returns String no formato YYYY-MM-DDTHH:mm:00 ou string vazia quando não houver data
*/
formatUtcForDateTimeLocalInput(date: string | null | undefined): string {
if (!date) return '';
return dayjs.utc(date).tz(getUserTimezone()).format('YYYY-MM-DDTHH:mm:00');
},
/**
* Inverso de `formatUtcForDateTimeLocalInput`: recebe uma string vinda de um
* input `datetime-local` (no timezone do usuário) e devolve uma ISO 8601 em
* UTC pronta para enviar à API. Retorna `null` quando não houver data.
* @param date - String do input (YYYY-MM-DDTHH:mm[:ss]) ou nulo
* @returns ISO 8601 em UTC (ex.: 2026-05-19T19:40:00Z) ou null
*/
formatLocalDateTimeForApi(date: string | null | undefined): string | null {
if (!date) return null;
return dayjs.tz(date, getUserTimezone()).utc().format();
},
/**
* Formata minutos para formato legível
* @param minutes - Número de minutos

View file

@ -96,7 +96,7 @@ const inviteUrl = computed(() => `/invites/${props.invite.id}`);
</Link>
</Button>
<Button as-child variant="outline" size="lg" class="w-full">
<Link :href="register({ query: { redirect: inviteUrl, email: invite.email } })">
<Link :href="register({ query: { redirect: inviteUrl, email: invite.email, invite: invite.id } })">
{{ $t('auth.accept_invite.create_account') }}
</Link>
</Button>

View file

@ -1,5 +1,6 @@
<script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3';
import { Form, Head, usePage } from '@inertiajs/vue3';
import { computed } from 'vue';
import SocialLogin from '@/components/auth/SocialLogin.vue';
import InputError from '@/components/InputError.vue';
@ -19,6 +20,9 @@ defineProps<{
email?: string | null;
redirect?: string | null;
}>();
const page = usePage();
const isSelfHosted = computed(() => Boolean(page.props.selfHosted));
</script>
<template>
@ -69,7 +73,7 @@ defineProps<{
</Button>
</div>
<div class="text-center text-sm text-muted-foreground">
<div v-if="!isSelfHosted" class="text-center text-sm text-muted-foreground">
{{ $t('auth.login.no_account') }}
<TextLink :href="register()" :tabindex="5">{{ $t('auth.login.sign_up') }}</TextLink>
</div>

View file

@ -13,6 +13,7 @@ import dayjs from '@/dayjs';
import AppLayout from '@/layouts/AppLayout.vue';
import { calendar } from '@/routes/app';
import { create as createPost, edit as editPost, show as showPost } from '@/routes/app/posts';
import { PostStatus } from '@/types/post';
interface PostPlatform {
id: string;
@ -230,8 +231,10 @@ const getStatusColor = (status: string): string => {
return colors[status] ?? colors.draft;
};
const EDITABLE_STATUSES: readonly string[] = [PostStatus.Draft, PostStatus.Scheduled];
const getPostUrl = (post: Post): string => {
return ['draft', 'scheduled', 'failed'].includes(post.status)
return EDITABLE_STATUSES.includes(post.status)
? editPost.url(post.id)
: showPost.url(post.id);
};

View file

@ -11,15 +11,18 @@ import PostEditorComposer from '@/components/posts/editor/PostEditorComposer.vue
import PostEditorHeader from '@/components/posts/editor/PostEditorHeader.vue';
import PostEditorSidebar from '@/components/posts/editor/PostEditorSidebar.vue';
import { usePostEcho } from '@/composables/echo/usePostEcho';
import { getMediaItemIssue } from '@/composables/useMedia';
import { getMediaRulesForContentType } from '@/composables/useMediaRules';
import { getPlatformLabel } from '@/composables/usePlatformLogo';
import {
firstCompatibleVariant,
getMediaIncompatibilityReason,
usePostCompliance,
} from '@/composables/usePostCompliance';
import date from '@/date';
import dayjs from '@/dayjs';
import debounce from '@/debounce';
import { Platform } from '@/enums/platform';
import AppLayout from '@/layouts/AppLayout.vue';
import { destroy as destroyPost, update as updatePost } from '@/routes/app/posts';
import type { PinterestBoard } from '@/types';
import { PostStatus } from '@/types/post';
interface MediaItem {
id: string;
@ -97,10 +100,15 @@ const props = defineProps<{
}>();
const post = computed(() => props.post);
// Terminal states the user cannot recover from — delete, navigate, but never edit.
const isReadOnly = computed(() => ['publishing', 'published', 'partially_published'].includes(post.value.status));
const isPublishing = computed(() => post.value.status === 'publishing');
const isScheduled = computed(() => post.value.status === 'scheduled');
const READONLY_STATUSES: readonly string[] = [
PostStatus.Publishing,
PostStatus.Published,
PostStatus.PartiallyPublished,
PostStatus.Failed,
];
const isReadOnly = computed(() => READONLY_STATUSES.includes(post.value.status));
const isPublishing = computed(() => post.value.status === PostStatus.Publishing);
const isScheduled = computed(() => post.value.status === PostStatus.Scheduled);
// Locked states — terminal + scheduled. Field edits and auto-save suppressed;
// user must unschedule to re-enter draft and edit.
const isLocked = computed(() => isReadOnly.value || isScheduled.value);
@ -132,219 +140,25 @@ const updatePlatformContentType = (platformId: string, contentType: string) => {
platformContentTypes.value = { ...platformContentTypes.value, [platformId]: contentType };
};
const platformLimits = computed(() => {
const seen = new Set<string>();
const result: { platform: string; maxLength: number }[] = [];
for (const pp of post.value.post_platforms) {
if (! selectedPlatformIds.value.includes(pp.id)) continue;
if (seen.has(pp.platform)) continue;
const accountId = pp.social_account_id;
const max = accountId ? props.platformConfigs[accountId]?.maxContentLength : null;
if (typeof max === 'number' && max > 0) {
seen.add(pp.platform);
result.push({ platform: pp.platform, maxLength: max });
}
}
return result;
});
const mediaIssues = computed(() => {
const result: Record<string, { platform: string; reason: string }[]> = {};
for (const item of media.value) {
const issues: { platform: string; reason: string }[] = [];
const seen = new Set<string>();
for (const pp of post.value.post_platforms) {
if (! selectedPlatformIds.value.includes(pp.id)) continue;
if (seen.has(pp.platform)) continue;
const contentType = platformContentTypes.value[pp.id] ?? pp.content_type ?? '';
const reason = getMediaItemIssue(item, contentType);
if (reason) {
seen.add(pp.platform);
issues.push({ platform: pp.platform, reason });
}
}
if (issues.length > 0) result[item.id] = issues;
}
return result;
});
const getMediaIncompatibilityReason = (contentType: string, mediaItems: MediaItem[]): string | null => {
const rules = getMediaRulesForContentType(contentType);
const videos = mediaItems.filter((m) => m.type === 'video' || m.mime_type?.startsWith('video/'));
const images = mediaItems.filter((m) => m.type === 'image' || m.mime_type?.startsWith('image/'));
const gifs = mediaItems.filter((m) => m.mime_type === 'image/gif');
const total = mediaItems.length;
// Order matters: type-mismatch errors (image vs video vs gif) are more
// fundamental than count errors. Telling the user "YouTube doesn't accept
// images" is more actionable than "too many files" when they're mixing types.
if (rules.requiresMedia && total === 0) return trans('posts.edit.compliance.requires_media');
if (!rules.acceptVideos && videos.length > 0) return trans('posts.edit.compliance.no_videos');
if (!rules.acceptImages && images.length > 0) return trans('posts.edit.compliance.no_images');
if (!rules.acceptsGif && gifs.length > 0) return trans('posts.edit.compliance.no_gifs');
if (total > rules.maxFiles) return trans('posts.edit.compliance.too_many_files', { max: String(rules.maxFiles) });
if (rules.minFiles && total < rules.minFiles) return trans('posts.edit.compliance.too_few_files', { min: String(rules.minFiles) });
for (const m of mediaItems) {
const isVideo = m.type === 'video' || m.mime_type?.startsWith('video/');
const size = m.size ?? 0;
const duration = m.meta?.duration ?? 0;
const width = m.meta?.width ?? 0;
const height = m.meta?.height ?? 0;
if (isVideo) {
if (rules.maxVideoBytes && size > 0 && size > rules.maxVideoBytes) return trans('posts.edit.compliance.video_too_large');
if (rules.maxVideoDurationSec && duration > 0 && duration > rules.maxVideoDurationSec) {
return trans('posts.edit.compliance.video_too_long', { seconds: String(rules.maxVideoDurationSec) });
}
} else if (rules.maxImageBytes && size > 0 && size > rules.maxImageBytes) {
return trans('posts.edit.compliance.image_too_large');
}
if (width > 0 && height > 0 && (rules.aspectRatioMin || rules.aspectRatioMax)) {
const ratio = width / height;
if (rules.aspectRatioMin && ratio < rules.aspectRatioMin) return trans('posts.edit.compliance.aspect_ratio_invalid');
if (rules.aspectRatioMax && ratio > rules.aspectRatioMax) return trans('posts.edit.compliance.aspect_ratio_invalid');
}
}
return null;
};
// Platforms whose default content_type doesn't accept every media type,
// so the tile would otherwise block silently when the user attaches the
// "wrong" kind. List the variants that should be considered at the tile
// level — togglePlatform snaps content_type to whichever fits the media.
//
// Instagram/Facebook/LinkedIn are intentionally NOT here: their defaults
// already accept image and video, and their secondary variants (Reel,
// Carousel) are picked manually by the user. Including them would hide
// real picker errors (e.g. Reel + image incompatibility) at the tile level.
const PLATFORM_VARIANTS: Record<string, string[]> = {
[Platform.TikTok]: ['tiktok_video', 'tiktok_photo'],
[Platform.Pinterest]: ['pinterest_pin', 'pinterest_video_pin', 'pinterest_carousel'],
};
const firstCompatibleVariant = (platform: string, mediaItems: MediaItem[]): string | null => {
const variants = PLATFORM_VARIANTS[platform];
if (!variants) return null;
return variants.find((ct) => !getMediaIncompatibilityReason(ct, mediaItems)) ?? null;
};
const platformIssues = computed<Record<string, string>>(() => {
const issues: Record<string, string> = {};
for (const pp of post.value.post_platforms) {
const contentType = platformContentTypes.value[pp.id];
if (!contentType) {
issues[pp.id] = trans('posts.edit.compliance.no_content_type');
continue;
}
// For platforms that expose multiple content types via a variant picker,
// the tile is only blocked when no variant fits — togglePlatform will
// switch to a compatible variant on selection.
if (PLATFORM_VARIANTS[pp.platform]) {
if (!firstCompatibleVariant(pp.platform, media.value)) {
issues[pp.id] = getMediaIncompatibilityReason(contentType, media.value) ?? '';
}
continue;
}
const reason = getMediaIncompatibilityReason(contentType, media.value);
if (reason) issues[pp.id] = reason;
}
return issues;
});
const mediaCompliancePerPlatformValid = computed(
() => selectedPlatformIds.value.every((id) => !platformIssues.value[id]),
);
// TikTok compliance per docs:
// - privacy_level must be explicitly selected
// - if disclosure toggle is ON, at least one sub-toggle must be selected
const tiktokComplianceValid = computed(() => {
const tiktokPlatforms = post.value.post_platforms.filter(
(pp) => pp.platform === Platform.TikTok && selectedPlatformIds.value.includes(pp.id),
);
return tiktokPlatforms.every((pp) => {
const meta = platformMeta.value[pp.id] ?? {};
if (!meta.privacy_level) return false;
if (meta.disclose && !meta.brand_organic_toggle && !meta.brand_content_toggle) return false;
return true;
});
});
// Pinterest needs a board_id selected to publish (the API rejects without it).
const pinterestComplianceValid = computed(() => {
const pinterestPlatforms = post.value.post_platforms.filter(
(pp) => pp.platform === Platform.Pinterest && selectedPlatformIds.value.includes(pp.id),
);
return pinterestPlatforms.every((pp) => Boolean(platformMeta.value[pp.id]?.board_id));
});
const contentLengthOverflows = computed(() => {
const len = content.value.length;
return platformLimits.value
.filter((p) => len > p.maxLength)
.map((p) => ({ platform: p.platform, limit: p.maxLength, over: len - p.maxLength }));
});
const canSchedule = computed(
() => mediaCompliancePerPlatformValid.value
&& tiktokComplianceValid.value
&& pinterestComplianceValid.value
&& contentLengthOverflows.value.length === 0,
);
const postActionTooltip = computed(() => {
if (canSchedule.value) return '';
const mediaReasons = post.value.post_platforms
.filter((pp) => selectedPlatformIds.value.includes(pp.id) && platformIssues.value[pp.id])
.map((pp) => `${pp.platform_name ?? pp.platform}: ${platformIssues.value[pp.id]}`);
const lengthReasons = contentLengthOverflows.value.map((overflow) => trans('posts.form.content_exceeds_platform', {
platform: getPlatformLabel(overflow.platform),
limit: String(overflow.limit),
over: String(overflow.over),
}));
const reasons = [...mediaReasons, ...lengthReasons];
if (reasons.length > 0) return reasons.join('\n');
// No media issues — the only remaining blocker is TikTok compliance.
// Surface the exact TikTok-required tooltip when the disclosure toggle
// is on without a sub-selection (UX Guideline Point 3a). For other TikTok
// cases (e.g. missing privacy_level), fall back to the generic message.
const tiktokDisclosureIncomplete = post.value.post_platforms.some((pp) => {
if (pp.platform !== Platform.TikTok) return false;
if (!selectedPlatformIds.value.includes(pp.id)) return false;
const meta = platformMeta.value[pp.id] ?? {};
return Boolean(meta.disclose) && !meta.brand_organic_toggle && !meta.brand_content_toggle;
});
if (tiktokDisclosureIncomplete) {
return trans('posts.form.tiktok.compliance_incomplete');
}
if (!pinterestComplianceValid.value) {
return trans('posts.form.pinterest.board_required');
}
return trans('posts.edit.compliance_incomplete');
const {
platformLimits,
mediaIssues,
platformIssues,
canSchedule,
postActionTooltip,
} = usePostCompliance({
post,
content,
media,
selectedPlatformIds,
platformContentTypes,
platformMeta,
platformConfigs: props.platformConfigs,
});
// Schedule
const getLocalSchedule = () => {
if (!post.value.scheduled_at) return '';
return dayjs.utc(post.value.scheduled_at).local().format('YYYY-MM-DDTHH:mm:00');
};
const scheduledDateTime = ref(getLocalSchedule());
const hasPickedTime = ref(post.value.status === 'scheduled' && !! post.value.scheduled_at);
const scheduledDateTime = ref(date.formatUtcForDateTimeLocalInput(post.value.scheduled_at));
const hasPickedTime = ref(post.value.status === PostStatus.Scheduled && !! post.value.scheduled_at);
const pickTimeLabel = computed(() => {
if (! hasPickedTime.value || ! scheduledDateTime.value) {
@ -384,21 +198,28 @@ const activeTab = ref(initialTabFromQuery);
const deleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
const editorSidebarRef = ref<InstanceType<typeof PostEditorSidebar> | null>(null);
const snapToCompatibleVariant = (platformId: string) => {
const pp = post.value.post_platforms.find((p) => p.id === platformId);
const current = platformContentTypes.value[platformId];
if (!pp || !current) return;
if (!getMediaIncompatibilityReason(current, media.value)) return;
const fallback = firstCompatibleVariant(pp.platform, media.value);
if (!fallback) return;
platformContentTypes.value = { ...platformContentTypes.value, [platformId]: fallback };
};
const togglePlatform = (platformId: string) => {
if (isLocked.value) return;
const index = selectedPlatformIds.value.indexOf(platformId);
if (index === -1) {
// For platforms with a variant picker, snap the post-platform's
// content_type to one that fits the current media before selection.
const pp = post.value.post_platforms.find((p) => p.id === platformId);
const variant = pp ? firstCompatibleVariant(pp.platform, media.value) : null;
if (variant && platformContentTypes.value[platformId] !== variant) {
platformContentTypes.value = { ...platformContentTypes.value, [platformId]: variant };
}
selectedPlatformIds.value.push(platformId);
} else {
selectedPlatformIds.value.splice(index, 1);
if (selectedPlatformIds.value.includes(platformId)) {
selectedPlatformIds.value = selectedPlatformIds.value.filter((id) => id !== platformId);
return;
}
snapToCompatibleVariant(platformId);
selectedPlatformIds.value.push(platformId);
};
// Save logic
@ -415,9 +236,7 @@ const getSubmitData = () => {
content: content.value,
media: media.value,
platforms,
scheduled_at: scheduledDateTime.value
? dayjs(scheduledDateTime.value).utc().format()
: null,
scheduled_at: date.formatLocalDateTimeForApi(scheduledDateTime.value),
label_ids: selectedLabelIds.value,
};
};
@ -462,7 +281,7 @@ onUnmounted(() => {
debouncedSave.cancel();
});
const submit = (status: string = 'scheduled') => {
const submit = (status: string = PostStatus.Scheduled) => {
if (isSubmitting.value || isReadOnly.value) return;
debouncedSave.cancel();
@ -499,13 +318,9 @@ const unschedulePost = () => {
debouncedSave.cancel();
scheduledDateTime.value = '';
hasPickedTime.value = false;
submit('draft');
submit(PostStatus.Draft);
};
// Echo: listen for real-time platform status updates.
// Event fires when any post_platform completes publishing (success or fail).
// Full reload of the post prop so the new status + post_platforms propagate and
// the overlay dismisses.
usePostEcho(post.value.id, '.post.platform.status.updated', () => {
router.reload({ only: ['post'] });
});

View file

@ -37,6 +37,7 @@ import dayjs from '@/dayjs';
import debounce from '@/debounce';
import AppLayout from '@/layouts/AppLayout.vue';
import { copyToClipboard } from '@/lib/utils';
import { PostStatus } from '@/types/post';
interface SocialAccount {
id: string;
platform: string;
@ -136,7 +137,10 @@ const getEnabledPlatforms = (post: Post) => post.post_platforms.filter((pp) => p
const getPostPreview = (post: Post): string =>
post.content?.trim() || trans('calendar.no_content');
const canEdit = (post: Post): boolean => ['draft', 'scheduled', 'failed'].includes(post.status);
const EDITABLE_STATUSES: readonly string[] = [PostStatus.Draft, PostStatus.Scheduled];
const DELETABLE_STATUSES: readonly string[] = [PostStatus.Draft, PostStatus.Scheduled, PostStatus.Failed];
const canEdit = (post: Post): boolean => EDITABLE_STATUSES.includes(post.status);
const canDelete = (post: Post): boolean => DELETABLE_STATUSES.includes(post.status);
const postUrl = (post: Post): string =>
canEdit(post) ? editPost.url(post.id) : showPost.url(post.id);
@ -301,7 +305,7 @@ useWorkspaceEcho(
<IconCopy class="size-4" />
{{ $t('posts.actions.copy_id') }}
</DropdownMenuItem>
<template v-if="canEdit(post)">
<template v-if="canDelete(post)">
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"

View file

@ -18,6 +18,7 @@ import { getPlatformStatusConfig, getPostStatusConfig } from '@/composables/useP
import dayjs from '@/dayjs';
import AppLayout from '@/layouts/AppLayout.vue';
import { index as postsIndex } from '@/routes/app/posts';
import { PostPlatformStatus, PostStatus } from '@/types/post';
interface MediaItem {
id: string;
@ -73,7 +74,7 @@ const props = defineProps<{
const enabledPlatforms = computed(() => props.post.platforms.filter((pp) => pp.enabled));
const isPublishing = computed(() => props.post.status === 'publishing');
const isPublishing = computed(() => props.post.status === PostStatus.Publishing);
const postStatus = computed(() => getPostStatusConfig(props.post.status));
@ -252,11 +253,11 @@ usePostEcho(props.post.id, '.post.platform.status.updated', () => {
<component
:is="getPlatformStatusConfig(pp.status).icon"
class="size-3"
:class="pp.status === 'publishing' ? 'animate-spin' : ''"
:class="pp.status === PostPlatformStatus.Publishing ? 'animate-spin' : ''"
/>
{{ getPlatformStatusConfig(pp.status).label }}
</Badge>
<TooltipProvider v-if="pp.status === 'published' && pp.platform_url" :delay-duration="200">
<TooltipProvider v-if="pp.status === PostPlatformStatus.Published && pp.platform_url" :delay-duration="200">
<Tooltip>
<TooltipTrigger as-child>
<a
@ -276,7 +277,7 @@ usePostEcho(props.post.id, '.post.platform.status.updated', () => {
<!-- Failed: error message -->
<div
v-if="pp.status === 'failed' && pp.error_message"
v-if="pp.status === PostPlatformStatus.Failed && pp.error_message"
class="border-t-2 border-foreground/10 bg-rose-50 px-4 py-3 text-xs font-semibold text-rose-700"
>
{{ pp.error_message }}
@ -284,7 +285,7 @@ usePostEcho(props.post.id, '.post.platform.status.updated', () => {
<!-- Published: metrics -->
<PostPlatformMetrics
v-if="pp.status === 'published'"
v-if="pp.status === PostPlatformStatus.Published"
:post-id="post.id"
:post-platform-id="pp.id"
/>

View file

@ -0,0 +1,20 @@
export const PostStatus = {
Draft: 'draft',
Scheduled: 'scheduled',
Publishing: 'publishing',
Published: 'published',
PartiallyPublished: 'partially_published',
Failed: 'failed',
} as const;
export type PostStatusValue = (typeof PostStatus)[keyof typeof PostStatus];
export const PostPlatformStatus = {
Pending: 'pending',
Publishing: 'publishing',
Published: 'published',
Failed: 'failed',
Retrying: 'retrying',
} as const;
export type PostPlatformStatusValue = (typeof PostPlatformStatus)[keyof typeof PostPlatformStatus];

View file

@ -85,9 +85,3 @@ export const uploadChunked = async (options: ChunkedUploadOptions): Promise<Chun
throw error;
}
};
const CHUNKED_UPLOAD_THRESHOLD = 10 * 1024 * 1024; // 10MB
export const shouldUseChunkedUpload = (file: File): boolean => {
return file.size > CHUNKED_UPLOAD_THRESHOLD;
};

View file

@ -8,9 +8,15 @@
use App\Http\Controllers\Api\PostController;
use App\Http\Controllers\Api\SignatureController;
use App\Http\Controllers\Api\SocialAccountController;
use App\Http\Controllers\Api\UploadController;
use App\Http\Controllers\Api\WorkspaceController;
use Illuminate\Support\Facades\Route;
Route::post('/uploads/{token}', [UploadController::class, 'store'])
->middleware(['signed', 'throttle:10,1'])
->where('token', '[0-9a-f-]{36}')
->name('api.uploads.store');
Route::middleware(['auth:api', 'workspace.token', 'throttle:api'])->group(function () {
// Posts
Route::get('/posts', [PostController::class, 'index'])->name('api.posts.index');

View file

@ -18,8 +18,10 @@
Route::get('/invites/{invite}', [AcceptInviteController::class, 'show'])->name('app.invites.show');
Route::middleware(['guest'])->group(function () {
Route::get('/register', [RegisteredUserController::class, 'create'])->name('register');
Route::post('/register', [RegisteredUserController::class, 'store'])->name('register.store');
Route::middleware('registration.enabled')->group(function () {
Route::get('/register', [RegisteredUserController::class, 'create'])->name('register');
Route::post('/register', [RegisteredUserController::class, 'store'])->name('register.store');
});
Route::get('/login', [AuthenticatedSessionController::class, 'create'])->name('login');
Route::post('/login', [AuthenticatedSessionController::class, 'store'])->name('login.store');

View file

@ -19,7 +19,9 @@
]);
});
test('show invite displays invite details for guest', function () {
test('show invite displays invite details for guest when not self_hosted', function () {
config()->set('trypost.self_hosted', false);
$invite = Invite::factory()->create([
'account_id' => $this->account->id,
'invited_by' => $this->owner->id,
@ -39,6 +41,26 @@
);
});
test('show invite displays invite details for guest when self_hosted (page renders, gate happens on /register)', function () {
config()->set('trypost.self_hosted', true);
$invite = Invite::factory()->create([
'account_id' => $this->account->id,
'invited_by' => $this->owner->id,
'email' => 'newuser@example.com',
'workspaces' => [$this->workspace->id],
]);
$response = $this->get(route('app.invites.show', $invite));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('auth/AcceptInvite', false)
->has('invite')
->where('invite.id', $invite->id)
);
});
test('show invite displays invite details for authenticated user', function () {
$user = User::factory()->create([
'email' => 'invitee@example.com',

Some files were not shown because too many files have changed in this diff Show more