Merge branch 'main' into feat/posthog-onboarding-properties
This commit is contained in:
commit
a659a9eadf
124 changed files with 3678 additions and 1404 deletions
78
.dockerignore
Normal file
78
.dockerignore
Normal 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
|
||||
|
|
@ -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
2
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
enum Action: string
|
||||
{
|
||||
case AlreadyPublished = 'already_published';
|
||||
case Finalized = 'finalized';
|
||||
case Publishing = 'publishing';
|
||||
case Scheduled = 'scheduled';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ enum Status: string
|
|||
{
|
||||
case Pending = 'pending';
|
||||
case Publishing = 'publishing';
|
||||
case Retrying = 'retrying';
|
||||
case Published = 'published';
|
||||
case Failed = 'failed';
|
||||
}
|
||||
|
|
|
|||
23
app/Exceptions/PlatformUnavailableException.php
Normal file
23
app/Exceptions/PlatformUnavailableException.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
|||
51
app/Http/Controllers/Api/UploadController.php
Normal file
51
app/Http/Controllers/Api/UploadController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
27
app/Http/Middleware/App/EnsureRegistrationEnabled.php
Normal file
27
app/Http/Middleware/App/EnsureRegistrationEnabled.php
Normal 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;
|
||||
}
|
||||
}
|
||||
38
app/Http/Requests/Api/StoreUploadRequest.php
Normal file
38
app/Http/Requests/Api/StoreUploadRequest.php
Normal 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}",
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
31
app/Http/Resources/Api/MediaUploadResource.php
Normal file
31
app/Http/Resources/Api/MediaUploadResource.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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', [
|
||||
|
|
|
|||
|
|
@ -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', [
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
74
app/Mcp/Tools/Post/AttachMediaFromUploadTool.php
Normal file
74
app/Mcp/Tools/Post/AttachMediaFromUploadTool.php
Normal 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.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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 */
|
||||
|
|
|
|||
50
app/Mcp/Tools/Post/RequestMediaUploadTool.php
Normal file
50
app/Mcp/Tools/Post/RequestMediaUploadTool.php
Normal 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 [];
|
||||
}
|
||||
}
|
||||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ class Media extends Model
|
|||
'size',
|
||||
'order',
|
||||
'meta',
|
||||
'upload_token',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
37
app/Services/Social/TokenRedactor.php
Normal file
37
app/Services/Social/TokenRedactor.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
74
app/Services/Social/TokenRefreshClient.php
Normal file
74
app/Services/Social/TokenRefreshClient.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
52
compose.override.yaml.example
Normal file
52
compose.override.yaml.example
Normal 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'
|
||||
138
compose.yaml
138
compose.yaml
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
],
|
||||
],
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
36
database/seeders/UserSeeder.php
Normal file
36
database/seeders/UserSeeder.php
Normal 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
163
docker/.env.docker.example
Normal 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
212
docker/Dockerfile
Normal 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
112
docker/entrypoint.sh
Executable 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
56
docker/nginx.conf
Normal 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
22
docker/php.dev.ini
Normal 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
20
docker/php.prod.ini
Normal 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
11
docker/postgres-init.sh
Executable 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
|
||||
69
docker/supervisord.dev.conf
Normal file
69
docker/supervisord.dev.conf
Normal 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
|
||||
58
docker/supervisord.prod.conf
Normal file
58
docker/supervisord.prod.conf
Normal 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
|
||||
|
|
@ -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.',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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.',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
14
resources/js/composables/usePageErrors.ts
Normal file
14
resources/js/composables/usePageErrors.ts
Normal 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>);
|
||||
};
|
||||
236
resources/js/composables/usePostCompliance.ts
Normal file
236
resources/js/composables/usePostCompliance.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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'] });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
20
resources/js/types/post.ts
Normal file
20
resources/js/types/post.ts
Normal 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];
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue